Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.9.1
|
VERSION: 3.9.2
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
@@ -72,7 +72,27 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install Trivy
|
- name: Install Trivy
|
||||||
run: |
|
run: |
|
||||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
# Install Trivy from the official Docker image instead of the
|
||||||
|
# upstream install.sh. Rationale:
|
||||||
|
# 1. install.sh resolves the positional tag against the
|
||||||
|
# GitHub releases API; when a release is unpublished or
|
||||||
|
# yanked, the script fails with
|
||||||
|
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||||
|
# even when the release once existed. We hit this with
|
||||||
|
# v0.71.0.
|
||||||
|
# 2. Docker Hub tags are content-addressed and rarely
|
||||||
|
# removed, so a pinned image tag is much more stable.
|
||||||
|
# 3. The image is multi-arch (linux/amd64, linux/arm64,
|
||||||
|
# linux/ppc64le, linux/s390x) so the same tag works on
|
||||||
|
# the GitHub-hosted runner and on the ARM64 Pi runner.
|
||||||
|
set -euo pipefail
|
||||||
|
TRIVY_VERSION="0.70.0"
|
||||||
|
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||||
|
docker rm trivy-tmp >/dev/null
|
||||||
|
chmod +x /usr/local/bin/trivy
|
||||||
|
trivy --version
|
||||||
|
|
||||||
- name: Scan Bot image
|
- name: Scan Bot image
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,7 +47,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Trivy
|
- name: Install Trivy
|
||||||
run: |
|
run: |
|
||||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
# Install Trivy from the official Docker image instead of the
|
||||||
|
# upstream install.sh. Rationale (see deploy.yml for the long
|
||||||
|
# version): the GitHub release tag we pinned (v0.71.0) was
|
||||||
|
# unpublished, and install.sh fails hard on missing tags.
|
||||||
|
# Docker Hub images are content-addressed and rarely removed,
|
||||||
|
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
|
||||||
|
set -euo pipefail
|
||||||
|
TRIVY_VERSION="0.70.0"
|
||||||
|
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||||
|
docker rm trivy-tmp >/dev/null
|
||||||
|
chmod +x /usr/local/bin/trivy
|
||||||
trivy --version
|
trivy --version
|
||||||
|
|
||||||
- name: Trivy filesystem security scan
|
- name: Trivy filesystem security scan
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.9.1</Version>
|
<Version>3.9.2</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+21
-1
@@ -1,4 +1,24 @@
|
|||||||
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
|
||||||
|
|
||||||
|
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync` → `PlatformNotSupportedException` → `GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
|
||||||
|
|
||||||
|
### 🩹 Что починено
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync` — `new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
|
||||||
|
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
|
||||||
|
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
|
||||||
|
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
|
||||||
|
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
|
||||||
|
|
||||||
|
### ⚠️ Известные ограничения
|
||||||
|
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
|
||||||
|
|
||||||
|
### 🧪 Тесты
|
||||||
|
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
|
||||||
|
|
||||||
|
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
||||||
|
|
||||||
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
|
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.1
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.2
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.1
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.2
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.1
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.2
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ public sealed class TelegramWizardMessenger(
|
|||||||
// and game_groups has no `club_id` FK). The picker therefore returns the
|
// and game_groups has no `club_id` FK). The picker therefore returns the
|
||||||
// game_groups the owner manages as a GM (via group_managers), matching
|
// game_groups the owner manages as a GM (via group_managers), matching
|
||||||
// the WizardClubOption contract (UUID id, name) used downstream.
|
// the WizardClubOption contract (UUID id, name) used downstream.
|
||||||
|
//
|
||||||
|
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
|
||||||
|
// (sql, object?) extension overload — not the (CommandDefinition) overload.
|
||||||
|
// The wizard reaches this method on the PickClub visibility step
|
||||||
|
// (issue #112 follow-up); using CommandDefinition here would fall back
|
||||||
|
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
|
||||||
|
// and throws PlatformNotSupportedException on AOT. Same root cause as
|
||||||
|
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT g.id AS ClubId,
|
SELECT g.id AS ClubId,
|
||||||
g.name AS Name
|
g.name AS Name
|
||||||
@@ -95,10 +103,8 @@ public sealed class TelegramWizardMessenger(
|
|||||||
""";
|
""";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
var rows = await connection.QueryAsync<WizardClubOption>(
|
var rows = await connection.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = "Telegram", ExternalId = ownerId });
|
||||||
new { Platform = "Telegram", ExternalId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ internal static class DiscordPermissionLookup
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||||
var rows = await connection.QueryAsync<ulong>(
|
var rows = await connection.QueryAsync<ulong>(
|
||||||
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
|
sql,
|
||||||
|
new { GuildId = guildId.ToString() });
|
||||||
return rows.ToList();
|
return rows.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,11 +537,10 @@ internal static class WizardClubLookup
|
|||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""";
|
""";
|
||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = "Discord", OwnerId = ownerId });
|
||||||
new { Platform = "Discord", OwnerId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,11 +164,11 @@ public sealed class DiscordWizardMessenger : IWizardMessenger
|
|||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""";
|
""";
|
||||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||||
|
// NativeAOT: direct (sql, params) overload — see
|
||||||
|
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
|
||||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = "Discord", ExternalId = ownerId });
|
||||||
new { Platform = "Discord", ExternalId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||||
|
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
|
|||||||
using NetCord.Services.ComponentInteractions;
|
using NetCord.Services.ComponentInteractions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
|
[module: Dapper.DapperAot]
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
builder.AddServiceDefaults();
|
builder.AddServiceDefaults();
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.9.1</div>
|
<div class="nav-version">v3.9.2</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
+45
-6
@@ -28,7 +28,7 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
|||||||
"Wizard",
|
"Wizard",
|
||||||
"WizardDraftRepository.cs"));
|
"WizardDraftRepository.cs"));
|
||||||
|
|
||||||
var getActive = ExtractMethodBody(source, "GetActiveAsync");
|
var getActive = ExtractMethodBody(source, "GetActiveAsync", "");
|
||||||
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
|
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,29 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
|||||||
"Wizard",
|
"Wizard",
|
||||||
"WizardDraftRepository.cs"));
|
"WizardDraftRepository.cs"));
|
||||||
|
|
||||||
var body = ExtractMethodBody(source, methodName);
|
var body = ExtractMethodBody(source, methodName, "");
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WizardDraftRepository was the only AOT-fatal site in v3.9.0, but the
|
||||||
|
/// same pattern (CommandDefinition on a Dapper extension that the AOT
|
||||||
|
/// generator cannot reach) is repeated in 4 club-picker / permission
|
||||||
|
/// lookups across Telegram and Discord messengers. v3.9.2 hotfix
|
||||||
|
/// converted them all to the direct (sql, params) overload. Lock the
|
||||||
|
/// regression so the next refactor doesn't reintroduce it.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs", "GetOwnerClubsAsync", "")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs", "GetOwnerClubsAsync", "")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs", "LoadClubsAsync", "internal static class WizardClubLookup")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs", "LoadManagerUserIdsAsync", "")]
|
||||||
|
public void ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition(string relativePath, string methodName, string containingClass)
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
|
||||||
|
var body = ExtractMethodBody(source, methodName, containingClass);
|
||||||
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +104,24 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
|||||||
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractMethodBody(string source, string methodName)
|
private static string ExtractMethodBody(string source, string methodName, string containingClass)
|
||||||
{
|
{
|
||||||
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
|
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// If a containing class is given (non-empty), narrow the search
|
||||||
|
// to the first occurrence AFTER the class declaration. This is
|
||||||
|
// needed when the same method name is used as a call site
|
||||||
|
// elsewhere in the file.
|
||||||
|
if (!string.IsNullOrEmpty(containingClass))
|
||||||
|
{
|
||||||
|
var classIdx = source.IndexOf(containingClass, StringComparison.Ordinal);
|
||||||
|
if (classIdx < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate class {containingClass} in source.");
|
||||||
|
}
|
||||||
|
searchFrom = source.IndexOf(methodName, classIdx, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
if (searchFrom < 0)
|
if (searchFrom < 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Could not locate {methodName} in source.");
|
throw new InvalidOperationException($"Could not locate {methodName} in source.");
|
||||||
@@ -92,9 +129,11 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
|||||||
|
|
||||||
// Accept any return type: `public async Task` (no result) or
|
// Accept any return type: `public async Task` (no result) or
|
||||||
// `public async Task<int>` (with result). Search for the keyword
|
// `public async Task<int>` (with result). Search for the keyword
|
||||||
// "Task" right before the method name.
|
// "Task" in a 60-char window before the method name so we also
|
||||||
var idx = source.IndexOf("Task", searchFrom - 16, StringComparison.Ordinal);
|
// pick up `public static async Task<IReadOnlyList<ulong>>`.
|
||||||
if (idx < 0 || !source.Substring(idx, 4).StartsWith("Task", StringComparison.Ordinal))
|
var windowStart = Math.Max(0, searchFrom - 60);
|
||||||
|
var idx = source.IndexOf("Task", windowStart, StringComparison.Ordinal);
|
||||||
|
if (idx < 0 || idx >= searchFrom)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
|
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||||
{
|
{
|
||||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||||
Assert.Contains("v3.9.1", navMenu, StringComparison.Ordinal);
|
Assert.Contains("v3.9.2", navMenu, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user