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

namespace Stardust.E2ETests.Tests;

/// <summary>看板中心端到端测试。覆盖五个看板加载、JSON 数据接口与主题切换</summary>
[Collection(StarWebCollection.Name)]
public class DashboardPageTests : IAsyncLifetime
{
    private static readonly (String Url, String Page)[] _pages =
    [
        ("/", "system"),
        ("/Dashboard/System", "system"),
        ("/Dashboard/Projects", "projects"),
        ("/Dashboard/Apps", "apps"),
        ("/Dashboard/Nodes", "nodes"),
        ("/Dashboard/Wallboard", "wallboard"),
    ];

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

    public DashboardPageTests(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 = 1600, Height = 1000 },
            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.EvaluateAsync("() => { var p = document.querySelector('#pwd'); if (p) p.value = document.querySelector('#password').value; }");
        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 = "登录后落地看板中心,后台入口指向 /Admin")]
    public async Task Login_LandsOnDashboard()
    {
        Assert.EndsWith("/", _page!.Url);
        Assert.DoesNotContain("/Login", _page.Url);

        var admin = await _page.GetAttributeAsync("a[title='管理后台']", "href");
        Assert.Equal("/Admin", admin);

        // 管理后台首页仍是魔方原生工作台,不嵌入看板
        var resp = await _page.GotoAsync("/Admin", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        Assert.Equal(200, resp?.Status);
        Assert.Equal(0, await _page.Locator(".sd-rail").CountAsync());
    }

    [Fact(DisplayName = "管理后台可以打开看板中心并跳出后台框架")]
    public async Task AdminMenu_OpensDashboard()
    {
        await _page!.GotoAsync("/Admin", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });

        var link = _page.Locator("[data-url='/Dashboard']").First;
        Assert.Equal(1, await link.CountAsync());

        // 菜单默认折叠,直接派发 click 事件触发 layui 的打开逻辑
        await link.DispatchEventAsync("click");
        await _page.WaitForURLAsync(url => url.Contains("/Dashboard", StringComparison.OrdinalIgnoreCase), new PageWaitForURLOptions { Timeout = 30_000 });

        // 跳出后台内容框架:只剩看板自己的侧栏,不再套后台外壳
        Assert.Equal(1, await _page.Locator(".sd-rail").CountAsync());
        Assert.Equal(0, await _page.Locator(".layui-side").CountAsync());
    }

    [Fact(DisplayName = "看板菜单已注册并可进入系统级看板")]
    public async Task DashboardMenu_Registered()
    {
        var menus = _fixture.GetMenuPages();
        Assert.Contains(menus, e => String.Equals(e.Url, "/Dashboard", StringComparison.OrdinalIgnoreCase) || e.Url.StartsWith("/Dashboard/", StringComparison.OrdinalIgnoreCase));

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

        await _page.WaitForURLAsync(url => url.Contains("/Dashboard/System", StringComparison.OrdinalIgnoreCase), new PageWaitForURLOptions { Timeout = 30_000 });
        Assert.Contains("星尘看板", await _page.TitleAsync());
    }

    [Fact(DisplayName = "五个看板页面均可访问且无脚本异常")]
    public async Task DashboardPages_LoadWithoutErrors()
    {
        var errors = new List<String>();
        _page!.PageError += (_, e) => errors.Add(e);

        foreach (var (url, page) in _pages)
        {
            var resp = await _page.GotoAsync(url, new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
            Assert.Equal(200, resp?.Status);

            await _page.WaitForFunctionAsync("() => !!window.SD && !!window.SDCharts", null, new PageWaitForFunctionOptions { Timeout = 15_000 });

            var body = await _page.Locator("body").InnerTextAsync();
            Assert.DoesNotContain("Server Error", body);
            Assert.DoesNotContain("An unhandled exception", body);

            var actual = await _page.EvaluateAsync<String>("() => document.documentElement.dataset.page");
            Assert.Equal(page, actual);

            _output.WriteLine($"{url} -> {resp?.Status},标题 {await _page.TitleAsync()}");
        }

        Assert.True(errors.Count == 0, "看板页面出现脚本异常:" + String.Join(" | ", errors));
    }

    [Fact(DisplayName = "看板数据接口返回 JSON 且包含范围信息")]
    public async Task DashboardData_ReturnsJson()
    {
        var result = await _page!.EvaluateAsync<DataProbe>(@"async () => {
            const r = await fetch('/Dashboard/Data?page=system&range=24h&id=0', { headers: { Accept: 'application/json' } });
            const body = await r.json();
            return { status: r.status, kpiCount: body.kpis ? body.kpis.length : 0, mode: body.scope ? body.scope.mode : '' };
        }");

        Assert.NotNull(result);
        Assert.Equal(200, result!.Status);
        Assert.Equal(6, result.KpiCount);
        Assert.False(String.IsNullOrEmpty(result.Mode));
    }

    /// <summary>数据接口探针结果</summary>
    public sealed class DataProbe
    {
        /// <summary>HTTP 状态码</summary>
        public Int32 Status { get; set; }

        /// <summary>KPI 数量</summary>
        public Int32 KpiCount { get; set; }

        /// <summary>可见范围模式</summary>
        public String? Mode { get; set; }
    }

    [Fact(DisplayName = "看板卡片均提供有效下钻链接")]
    public async Task Cards_HaveDrilldownLinks()
    {
        await _page!.GotoAsync("/Dashboard/System", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD", null, new PageWaitForFunctionOptions { Timeout = 15_000 });

        var cards = _page.Locator("[data-card]");
        var count = await cards.CountAsync();
        Assert.True(count >= 12, $"系统看板卡片数量异常:{count}");

        for (var i = 0; i < count; i++)
        {
            var card = cards.Nth(i);
            var id = await card.GetAttributeAsync("data-card");
            var anchors = card.Locator("a[href]");
            var link = await anchors.CountAsync() > 0 ? await anchors.First.GetAttributeAsync("href") : null;
            if (String.IsNullOrWhiteSpace(link)) link = await card.GetAttributeAsync("data-kpi-url");

            Assert.False(String.IsNullOrWhiteSpace(link), $"卡片 {id} 缺少下钻链接");
            Assert.StartsWith("/", link);
        }
    }

    [Fact(DisplayName = "布局调整可持久化并可恢复默认")]
    public async Task Layout_ChangesPersistAndReset()
    {
        await _page!.GotoAsync("/Dashboard/System", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD && !!window.SD.layout", null, new PageWaitForFunctionOptions { Timeout = 15_000 });
        await _page.EvaluateAsync(@"() => {
            Object.keys(localStorage)
                .filter(k => k.indexOf('sd-dashboard-layout:') === 0)
                .forEach(k => localStorage.removeItem(k));
        }");
        await _page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });

        var card = _page.Locator("[data-card=\"system-top-apps\"]");
        Assert.True(await card.IsVisibleAsync());

        await _page.ClickAsync("[data-action=\"layout\"]");
        var row = _page.Locator("[data-layout-id=\"system-top-apps\"]");
        await row.Locator("[data-layout-size]").SelectOptionAsync("24");
        await row.Locator("[data-layout-visible]").UncheckAsync();
        await _page.ClickAsync(".sd-layout__foot [data-layout-close]");

        await _page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD && !!window.SD.layout", null, new PageWaitForFunctionOptions { Timeout = 15_000 });
        Assert.False(await card.IsVisibleAsync());
        Assert.Equal("24", await card.GetAttributeAsync("data-size"));

        await _page.ClickAsync("[data-action=\"layout\"]");
        await _page.ClickAsync("[data-layout-reset]");
        await _page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
        await _page.WaitForFunctionAsync("() => !!window.SD && !!window.SD.layout", null, new PageWaitForFunctionOptions { Timeout = 15_000 });
        Assert.True(await card.IsVisibleAsync());
    }

    [Fact(DisplayName = "告警卡片可确认与静默")]
    public async Task AlarmActions_AckAndSilence()
    {
        var dataDir = Path.GetDirectoryName(_fixture.MembershipDb);
        Assert.False(String.IsNullOrEmpty(dataDir));

        var dbFile = Path.Combine(dataDir!, "Stardust.db");
        Assert.True(File.Exists(dbFile), $"未找到星尘数据库:{dbFile}");

        var id = DateTime.UtcNow.Ticks;
        var category = "看板E2E告警";
        var action = "E2E对象";

        using (var conn = new SqliteConnection($"Data Source={dbFile};Mode=ReadWrite;Default Timeout=30"))
        {
            conn.Open();
            using var cmd = conn.CreateCommand();
            cmd.CommandText = @"insert into AlarmRecord (Id, Name, GroupId, Category, Action, Status, StartTime, Duration, Times, Content, AckTime, AckUser, SilenceTime, Creator, CreateTime, UpdateTime)
values (@id, @name, 0, @category, @action, 1, @start, 0, 0, @content, null, null, null, 'e2e', @start, @start)";
            cmd.Parameters.AddWithValue("@id", id);
            cmd.Parameters.AddWithValue("@name", $"{category}-{action}");
            cmd.Parameters.AddWithValue("@category", category);
            cmd.Parameters.AddWithValue("@action", action);
            cmd.Parameters.AddWithValue("@start", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            cmd.Parameters.AddWithValue("@content", "E2E 确认与静默测试");
            cmd.ExecuteNonQuery();
        }

        // 登录跳转已经加载过默认 24h 看板并写入缓存,这里用 1h 避免命中最新的旧缓存
        await _page!.GotoAsync("/Dashboard/System?range=1h", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD", null, new PageWaitForFunctionOptions { Timeout = 15_000 });

        var actions = _page.Locator($"[data-alarm-actions=\"{id}\"]");
        await actions.WaitForAsync(new LocatorWaitForOptions { Timeout = 15_000 });

        await actions.Locator("[data-alarm-action=\"ack\"]").ClickAsync();
        await _page.WaitForTimeoutAsync(600);
        Assert.Contains("已确认", await actions.InnerTextAsync());

        await actions.Locator("[data-alarm-action=\"silence\"]").ClickAsync();
        await _page.WaitForTimeoutAsync(600);
        Assert.Contains("静默至", await actions.InnerTextAsync());

        using var verify = new SqliteConnection($"Data Source={dbFile};Mode=ReadOnly;Default Timeout=30");
        verify.Open();
        using var read = verify.CreateCommand();
        read.CommandText = "select AckUser, AckTime, SilenceTime from AlarmRecord where Id=@id";
        read.Parameters.AddWithValue("@id", id);
        using var reader = read.ExecuteReader();
        Assert.True(reader.Read());
        Assert.Equal(_fixture.UserName, reader.GetString(0));
        Assert.False(reader.IsDBNull(1));
        Assert.False(reader.IsDBNull(2));
    }

    [Fact(DisplayName = "大屏视角设置可持久化")]
    public async Task WallboardSettings_Persist()
    {
        await _page!.GotoAsync("/Dashboard/Wallboard", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD", null, new PageWaitForFunctionOptions { Timeout = 15_000 });
        await _page.EvaluateAsync("() => localStorage.removeItem('sd-wallboard-settings:v1')");
        await _page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });

        await _page.ClickAsync("[data-action=\"wallboard-settings\"]");
        var panel = _page.Locator("#wbSettings");
        await panel.WaitForAsync(new LocatorWaitForOptions { Timeout = 10_000 });
        await panel.Locator("[data-wb-view=\"1\"]").UncheckAsync();
        await panel.Locator("[data-wb-interval]").SelectOptionAsync("30000");
        await panel.Locator(".wb-settings__foot [data-wb-close]").ClickAsync();

        await _page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD", null, new PageWaitForFunctionOptions { Timeout = 15_000 });

        Assert.False(await _page.Locator("#wbDots button[data-view=\"1\"]").IsVisibleAsync());

        await _page.ClickAsync("[data-action=\"wallboard-settings\"]");
        panel = _page.Locator("#wbSettings");
        await panel.WaitForAsync(new LocatorWaitForOptions { Timeout = 10_000 });
        Assert.False(await panel.Locator("[data-wb-view=\"1\"]").IsCheckedAsync());
        Assert.Equal("30000", await panel.Locator("[data-wb-interval]").InputValueAsync());

        await panel.Locator(".wb-settings__foot [data-wb-close]").ClickAsync();
        await _page.EvaluateAsync("() => localStorage.removeItem('sd-wallboard-settings:v1')");
    }

    [Fact(DisplayName = "主题三态切换生效且不刷新页面")]
    public async Task ThemeSwitch_AppliesWithoutReload()
    {
        await _page!.GotoAsync("/Dashboard/System", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 });
        await _page.WaitForFunctionAsync("() => !!window.SD", null, new PageWaitForFunctionOptions { Timeout = 15_000 });

        await _page.EvaluateAsync("() => { window.__themeMarker = 1; }");

        var buttons = _page.Locator("[data-theme-switch] button[data-theme-mode]");
        var count = await buttons.CountAsync();
        Assert.True(count >= 3, $"主题切换应有三种模式,实际 {count} 个按钮");

        await buttons.Nth(2).ClickAsync();  // 深色
        await _page.WaitForTimeoutAsync(500);
        var dark = await _page.EvaluateAsync<String>("() => document.documentElement.dataset.theme");
        Assert.Equal("dark", dark);
        var marker = await _page.EvaluateAsync<Double>("() => window.__themeMarker || 0");
        Assert.Equal(1d, marker);

        await buttons.Nth(1).ClickAsync();  // 浅色
        await _page.WaitForTimeoutAsync(500);
        var light = await _page.EvaluateAsync<String>("() => document.documentElement.dataset.theme");
        Assert.Equal("light", light);
    }
}