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

namespace Stardust.E2ETests.Tests;

/// <summary>调用链页面端到端测试。覆盖链路查询页可加载、无 JS 崩溃、异常编号友好降级(APM-16)</summary>
[Collection(StarWebCollection.Name)]
public class TracePageTests : IAsyncLifetime
{
    private static readonly String[] _errorMarkers =
    [
        "Server Error",
        "服务器错误",
        "An unhandled exception",
        "System.NullReferenceException",
        "System.InvalidOperationException",
        "XCode.Exceptions.XSqlException",
        "SQL logic error",
    ];

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

    public TracePageTests(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 },
            Locale = "zh-CN",
        });
        _page = await _context.NewPageAsync();

        await _page.GotoAsync("/Admin/User/Login", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        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();
    }

    /// <summary>打开指定页面,收集控制台/页面异常与服务端错误标记</summary>
    private async Task<(Int32 Status, String Body, List<String> Errors)> OpenAsync(String url)
    {
        var page = _page!;
        var errors = new List<String>();

        void OnPageError(Object? sender, String err) => errors.Add("JS 异常:" + err);

        page.PageError += OnPageError;
        try
        {
            var resp = await page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
            Assert.Equal(200, resp?.Status);

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

            var body = await page.Locator("body").InnerTextAsync();
            var marker = _errorMarkers.FirstOrDefault(e => body.Contains(e, StringComparison.OrdinalIgnoreCase));
            if (marker != null) errors.Add("响应含错误标记:" + marker);

            // 会话失效会被悠回登录页,这里必须确认仍在目标页面
            if (page.Url.Contains("/Admin/User/Login", StringComparison.OrdinalIgnoreCase)) errors.Add("会话失效被踢回登录页");

            return (resp?.Status ?? 0, body, errors);
        }
        finally
        {
            page.PageError -= OnPageError;
        }
    }

    [Fact(DisplayName = "链路查询页可加载且无 JS 崩溃")]
    public async Task TracePage_Loads()
    {
        var (status, body, errors) = await OpenAsync("/Monitors/Trace");

        _output.WriteLine($"status={status} bodyLength={body.Length} errors={String.Join(" | ", errors)}");

        Assert.Equal(200, status);
        Assert.Contains("全链路追踪", body);
        Assert.Empty(errors);

        // 自定义页面不会自动带标题,控制器需显式设置,否则浏览器标签页为空
        var title = await _page!.TitleAsync();
        Assert.Contains("链路查询", title);
    }

    [Fact(DisplayName = "不存在的追踪编号给出降级提示而不崩溃")]
    public async Task TracePage_UnknownTraceId_Degrades()
    {
        var (status, body, errors) = await OpenAsync("/Monitors/Trace?id=not-exists-" + Guid.NewGuid().ToString("n")[..8]);

        _output.WriteLine($"status={status} bodyLength={body.Length} errors={String.Join(" | ", errors)}");

        Assert.Equal(200, status);

        // 无数据时页面应展示查询摘要(0 次)而不是报错
        Assert.Contains("查询", body);
        Assert.Contains("0次", body);
        Assert.Empty(errors);
    }

    [Fact(DisplayName = "登录用户生成分享链接,匿名可只读查看")]
    public async Task TracePage_ShareLink_ReadOnly()
    {
        var id = "not-exists-" + Guid.NewGuid().ToString("n")[..8];

        // 分享入口需要登录,生成后应跳回带令牌的调用链地址
        await _page!.GotoAsync($"/trace/share?id={id}", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        var shareUrl = _page.Url;
        _output.WriteLine($"share={shareUrl}");

        Assert.Contains("/trace?id=", shareUrl);
        Assert.Contains("token=", shareUrl);

        // 生成链接后给分享者明确反馈,避免"点了没反应"的错觉
        Assert.Contains("分享链接已生成", await _page.Locator("body").InnerTextAsync());

        // 未登录上下文打开分享链接,应可查看且标记为只读
        var context = await _browser!.NewContextAsync(new BrowserNewContextOptions
        {
            BaseURL = _fixture.BaseUrl,
            IgnoreHTTPSErrors = true,
            Locale = "zh-CN",
        });
        try
        {
            var page = await context.NewPageAsync();
            var resp = await page.GotoAsync(shareUrl, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });

            Assert.Equal(200, resp?.Status);

            var body = await page.Locator("body").InnerTextAsync();
            Assert.Contains("通过分享链接查看", body);

            // 浏览器标签页标题不再是空白
            Assert.Contains("调用链", await page.TitleAsync());
        }
        finally
        {
            await context.DisposeAsync();
        }
    }
}