节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
9.61 KiB
Stardust
using System.Diagnostics;
using System.Text;
using Microsoft.Playwright;
using Stardust.E2ETests.Fixtures;
using Xunit;
using Xunit.Abstractions;

namespace Stardust.E2ETests.Tests;

/// <summary>StarWeb 全站页面冒烟:登录、逐页访问菜单页面并断言无服务端错误与 JS 崩溃</summary>
[Collection(StarWebCollection.Name)]
public class StarWebSmokeTests : IAsyncLifetime
{
    private static readonly String[] _errorMarkers =
    [
        "Server Error",
        "服务器错误",
        "An unhandled exception",
        "System.NullReferenceException",
        "System.InvalidOperationException",
        "XCode.Exceptions.XSqlException",
        "SQL logic error",
    ];

    /// <summary>已知的第三方/外部问题:只做告警,不计入回归失败</summary>
    private static readonly Dictionary<String, String> _knownIssues = new(StringComparer.OrdinalIgnoreCase)
    {
        ["/Cube/Area"] = "百度地图 AK 未授权(getscript 返回 unauth=1),ECharts bmap 抛 getAxesOnZeroOf;属第三方地图密钥失效",
    };

    private readonly StarWebAppFixture _fixture;
    private readonly ITestOutputHelper _output;
    private IPlaywright? _playwright;
    private IBrowser? _browser;
    private IBrowserContext? _context;
    private IPage? _page;

    public StarWebSmokeTests(StarWebAppFixture fixture, ITestOutputHelper output)
    {
        _fixture = fixture;
        _output = output;
    }

    public async Task InitializeAsync()
    {
        _playwright = await Playwright.CreateAsync();
        _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
        _context = await _browser.NewContextAsync(new BrowserNewContextOptions
        {
            BaseURL = _fixture.BaseUrl,
            IgnoreHTTPSErrors = true,
            ViewportSize = new ViewportSize { Width = 1440, Height = 900 },
            // 站点按 Accept-Language 协商界面语言,固定浏览器区域语言以保证标题断言的确定性
            Locale = "zh-CN",
        });
        _page = await _context.NewPageAsync();

        var resp = await _page.GotoAsync("/Admin/User/Login", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        Assert.Equal(200, resp?.Status);

        await _page.FillAsync("#username", _fixture.UserName);
        await _page.FillAsync("#password", _fixture.Password);
        await _page.ClickAsync("#login-btn");
        await _page.WaitForURLAsync(url => !url.Contains("/Admin/User/Login", StringComparison.OrdinalIgnoreCase), new PageWaitForURLOptions { Timeout = 30_000 });
    }

    public async Task DisposeAsync()
    {
        if (_context != null) await _context.DisposeAsync();
        if (_browser != null) await _browser.DisposeAsync();
        _playwright?.Dispose();
    }

    [Fact(DisplayName = "登录后进入管理后台")]
    public async Task Login_EntersAdmin()
    {
        Assert.Contains("/Admin", _page!.Url, StringComparison.OrdinalIgnoreCase);

        // 站点标题来自程序集标题(当前为英文源文,浏览器标题未走界面语言翻译),故中英文名均视为正确
        var title = await _page.TitleAsync();
        Assert.True(title.Contains("星尘", StringComparison.Ordinal) || title.Contains("Stardust", StringComparison.OrdinalIgnoreCase),
            $"管理后台标题应包含站点名称,实际:{title}");
    }

    [Fact(DisplayName = "未登录访问业务页面被重定向到登录页")]
    public async Task Anonymous_RedirectedToLogin()
    {
        var context = await _browser!.NewContextAsync(new BrowserNewContextOptions { BaseURL = _fixture.BaseUrl });
        var page = await context.NewPageAsync();
        var resp = await page.GotoAsync("/Configs/AppConfig", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });

        var status = resp?.Status ?? 0;
        var redirected = page.Url.Contains("/Admin/User/Login", StringComparison.OrdinalIgnoreCase);
        var denied = status is 401 or 403;

        Assert.True(redirected || denied, $"未登录访问业务页面应跳转登录或被拒绝,实际 status={status} url={page.Url}");
        await context.DisposeAsync();
    }

    [Fact(DisplayName = "全部菜单页面:可访问、无服务端错误、无 JS 崩溃")]
    public async Task AllMenuPages_LoadNormally()
    {
        var menus = _fixture.GetMenuPages();
        _output.WriteLine($"菜单页面数量:{menus.Count}");
        Assert.NotEmpty(menus);

        var results = new List<PageCheckResult>();
        foreach (var menu in menus)
        {
            var result = await CheckPageAsync(menu);
            results.Add(result);
            var flag = result.Ok ? "OK" : result.Known ? "WARN" : "FAIL";
            _output.WriteLine($"[{flag}] {menu.Name} {menu.Url} -> {result.Status} {result.ElapsedMs}ms {(result.Known ? result.KnownNote : result.Detail)}");
        }

        WriteReport(results);

        var failures = results.Where(e => !e.Ok && !e.Known).ToList();
        Assert.True(failures.Count == 0, $"有 {failures.Count}/{results.Count} 个页面异常:{Environment.NewLine}" +
            String.Join(Environment.NewLine, failures.Select(e => $"  {e.Name} {e.Url} -> {e.Status} {e.Detail}")));
    }

    private async Task<PageCheckResult> CheckPageAsync(MenuPage menu)
    {
        var page = _page!;
        var consoleErrors = new List<String>();
        var pageErrors = new List<String>();

        void OnConsole(Object? sender, IConsoleMessage msg)
        {
            if (msg.Type == "error") consoleErrors.Add(msg.Text);
        }

        void OnPageError(Object? sender, String err) => pageErrors.Add(err);

        page.Console += OnConsole;
        page.PageError += OnPageError;

        var sw = Stopwatch.StartNew();
        var status = 0;
        var title = "";
        var detail = "";
        var body = "";
        try
        {
            var resp = await page.GotoAsync(menu.Url, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
            status = resp?.Status ?? 0;

            try
            {
                await page.WaitForLoadStateAsync(LoadState.NetworkIdle, new PageWaitForLoadStateOptions { Timeout = 5_000 });
            }
            catch (TimeoutException)
            {
                // 长轮询页面不会进入 networkidle,忽略
            }

            title = await page.TitleAsync();
            body = await page.Locator("body").InnerTextAsync();
        }
        catch (Exception ex)
        {
            detail = ex.Message;
        }
        finally
        {
            page.Console -= OnConsole;
            page.PageError -= OnPageError;
            sw.Stop();
        }

        var reasons = new List<String>();
        if (status is 0 or >= 400) reasons.Add($"HTTP {status}");
        if (detail.Length > 0) reasons.Add(detail);
        if (page.Url.Contains("/Admin/User/Login", StringComparison.OrdinalIgnoreCase)) reasons.Add("会话失效被踢回登录页");
        if (pageErrors.Count > 0) reasons.Add($"JS 异常 {pageErrors.Count}:{String.Join(" | ", pageErrors.Take(2))}");
        if (body.Length < 20 && title.Length == 0) reasons.Add("页面空白");

        var marker = _errorMarkers.FirstOrDefault(e => body.Contains(e, StringComparison.OrdinalIgnoreCase));
        if (marker != null) reasons.Add($"响应含错误标记[{marker}]");

        var ok = reasons.Count == 0;
        var known = !ok && _knownIssues.TryGetValue(menu.Url, out var note);
        var knownNote = known ? _knownIssues[menu.Url] : "";
        if (!ok)
        {
            try
            {
                var dir = Path.Combine(_fixture.RepoRoot, "TestResults", "starweb-screenshots");
                Directory.CreateDirectory(dir);
                await page.ScreenshotAsync(new PageScreenshotOptions { Path = Path.Combine(dir, Sanitize(menu.Url) + ".png") });
            }
            catch
            {
                // 截图失败不影响断言
            }
        }

        return new PageCheckResult(menu.Name, menu.Url, status, sw.ElapsedMilliseconds, title, consoleErrors.Count, ok, known, knownNote,
            String.Join(";", reasons));
    }

    private void WriteReport(List<PageCheckResult> results)
    {
        var dir = Path.Combine(_fixture.RepoRoot, "TestResults");
        Directory.CreateDirectory(dir);

        var sb = new StringBuilder();
        sb.AppendLine("# StarWeb 全站页面巡检");
        sb.AppendLine();
        sb.AppendLine($"- 站点:{_fixture.BaseUrl}");
        sb.AppendLine($"- 时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
        sb.AppendLine($"- 页面:{results.Count},通过 {results.Count(e => e.Ok)},已知问题 {results.Count(e => e.Known)},失败 {results.Count(e => !e.Ok && !e.Known)}");
        sb.AppendLine();
        sb.AppendLine("| 页面 | 地址 | 状态 | 耗时(ms) | JS错误 | 标题 | 结果 |");
        sb.AppendLine("|---|---|---|---|---|---|---|");
        foreach (var r in results)
        {
            var result = r.Ok ? "✅" : r.Known ? "⚠️ 已知问题:" + r.KnownNote : "❌ " + r.Detail;
            sb.AppendLine($"| {r.Name} | {r.Url} | {r.Status} | {r.ElapsedMs} | {r.ConsoleErrors} | {r.Title} | {result} |");
        }

        File.WriteAllText(Path.Combine(dir, "starweb-pages.md"), sb.ToString(), Encoding.UTF8);
    }

    private static String Sanitize(String url) => url.Trim('/').Replace('/', '_').Replace('?', '_').Replace('&', '_');

    private sealed record PageCheckResult(String Name, String Url, Int32 Status, Int64 ElapsedMs, String Title, Int32 ConsoleErrors, Boolean Ok, Boolean Known, String KnownNote, String Detail);
}