Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions .github/scripts/TestShards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
//
// Every lane but the last lists what it runs; the last runs everything the others don't list. So a
// test this script fails to find, or one added since, still runs exactly once, in the last lane.
//
// Tests in an excluded category (ExcludedCategories below: the live-MySQL tests, which run in their
// own job against a MySQL server) are left out of the plan, and every lane's filter excludes them.

using System.Reflection;
using System.Reflection.Metadata;
Expand All @@ -26,6 +29,8 @@
return 2;
}

string[] ExcludedCategories = ["MySql"];

var assemblyPath = args[0];
var laneCount = int.Parse(args[1]);
var outputDir = args[2];
Expand All @@ -37,7 +42,7 @@
return 2;
}

var classes = FindTests(assemblyPath);
var classes = FindTests(assemblyPath, ExcludedCategories);
if (classes.Count == 0)
{
Console.Error.WriteLine($"no [TestClass] types found in {assemblyPath}");
Expand Down Expand Up @@ -85,6 +90,8 @@ double ClassWeight(string cls) =>
filter = listed.Count > 0
? string.Join("&", listed.Select(t => t.Replace("=", "!=")))
: "FullyQualifiedName!=__run_everything__";
// & binds tighter than |, so a lane's list of alternatives is grouped before the exclusion.
filter = $"({filter})" + string.Concat(ExcludedCategories.Select(c => $"&TestCategory!={c}"));
File.WriteAllText(Path.Combine(outputDir, $"lane-{i}.filter"), filter);
}

Expand All @@ -111,8 +118,9 @@ double ClassWeight(string cls) =>
File.AppendAllLines(summary, report.Prepend("### Test lanes").Append(""));
return 0;

// Test class full name -> test method name -> number of cases (each [DataRow] is one).
static Dictionary<string, Dictionary<string, int>> FindTests(string path)
// Test class full name -> test method name -> number of cases (each [DataRow] is one). A method
// with, or in a class with, a [TestCategory] in excluded isn't listed.
static Dictionary<string, Dictionary<string, int>> FindTests(string path, string[] excluded)
{
using var stream = File.OpenRead(path);
using var pe = new PEReader(stream);
Expand All @@ -122,15 +130,17 @@ static Dictionary<string, Dictionary<string, int>> FindTests(string path)
foreach (var handle in md.TypeDefinitions)
{
var type = md.GetTypeDefinition(handle);
if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute"))
if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute")
|| Categories(md, type.GetCustomAttributes()).Intersect(excluded).Any())
continue;

var methods = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var methodHandle in type.GetMethods())
{
var method = md.GetMethodDefinition(methodHandle);
var attributes = method.GetCustomAttributes();
if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute"))
if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute")
|| Categories(md, attributes).Intersect(excluded).Any())
continue;
methods[md.GetString(method.Name)] =
Math.Max(1, attributes.Count(a => AttributeName(md, md.GetCustomAttribute(a)) == "DataRowAttribute"));
Expand All @@ -146,6 +156,20 @@ static Dictionary<string, Dictionary<string, int>> FindTests(string path)
static bool HasAttribute(MetadataReader md, CustomAttributeHandleCollection attributes, string name) =>
attributes.Any(a => AttributeName(md, md.GetCustomAttribute(a)) == name);

// The names in [TestCategory("...")] attributes: a blob of the 0x0001 prolog and one string argument.
static IEnumerable<string> Categories(MetadataReader md, CustomAttributeHandleCollection attributes)
{
foreach (var handle in attributes)
{
var attribute = md.GetCustomAttribute(handle);
if (AttributeName(md, attribute) != "TestCategoryAttribute")
continue;
var blob = md.GetBlobReader(attribute.Value);
if (blob.ReadUInt16() == 1 && blob.ReadSerializedString() is { } category)
yield return category;
}
}

static string? AttributeName(MetadataReader md, CustomAttribute attribute)
{
switch (attribute.Constructor.Kind)
Expand Down
86 changes: 82 additions & 4 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,95 @@ jobs:
status=0
for context in SqliteAuthContext MySqlAuthContext SqliteCharContext MySqlCharContext SqliteWorldContext MySqlWorldContext; do
echo "::group::$context"
if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then
if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then
echo "::error::$context has model changes without a migration (or could not be checked)."
status=1
fi
echo "::endgroup::"
done
exit $status

# The MySql test category against a real server, after every MySQL migration is applied to it
# from empty, the way a MySQL deployment gets its schema. The other lanes leave this category
# out (TestShards.cs) and only build the MySQL model offline.
mysql:
name: mysql (${{ matrix.mysql }})
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
mysql: [ '8.0', '8.4' ]
services:
mysql:
image: mysql:${{ matrix.mysql }}
env:
MYSQL_ROOT_PASSWORD: rasa-ci
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -prasa-ci"
--health-interval 5s
--health-timeout 5s
--health-retries 30
env:
RASA_TEST_MYSQL: Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci
steps:
- uses: actions/checkout@v7
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Restore NuGet cache
uses: actions/cache/restore@v6
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }}
restore-keys: nuget-${{ runner.os }}-
# The design-time factories read databasesettings.json and then databasesettings.env.json from
# the build output, the same files the servers read; the override points them at the service.
# The timeout is also the command timeout, and the seed migrations insert thousands of rows.
- name: Point the database settings at the service
run: |
jq '.Databases |= ((.Auth, .Char, .World) |= (.Host = "127.0.0.1" | .Port = 3306 | .User = "root" | .Password = "rasa-ci"
| .TimeoutInMilliseconds = 600000))' \
src/Rasa.DBL/databasesettings.json > src/Rasa.DBL/databasesettings.env.json
- run: dotnet tool restore
- name: Build
run: dotnet build src/Rasa.Test
- name: Apply the MySQL migrations
run: |
for context in MySqlAuthContext MySqlCharContext MySqlWorldContext; do
dotnet ef database update --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"
done
- name: Test
run: |
dotnet test src/Rasa.Test --no-build \
--filter TestCategory=MySql \
--blame-hang-timeout 10m \
--logger "trx;LogFileName=mysql.trx" \
--results-directory TestResults
ran=$(grep -o '<UnitTestResult ' TestResults/mysql.trx | wc -l)
passed=$(grep -o '<UnitTestResult [^>]*outcome="Passed"' TestResults/mysql.trx | wc -l)
echo "MySQL ${{ matrix.mysql }}: $ran results, $passed passed." | tee -a "$GITHUB_STEP_SUMMARY"
if [ "$passed" -eq 0 ] || [ "$passed" -ne "$ran" ]; then
echo "::error::Every MySql test must run and pass against the server ($passed of $ran passed)."
exit 1
fi
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: mysql-results-${{ matrix.mysql }}
path: TestResults/
retention-days: 14

# The one check to require: it passes when every lane passed, or when the PR changed only docs.
tests-complete:
name: tests complete
needs: [ changes, build, test, drift ]
needs: [ changes, build, test, drift, mysql ]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
Expand All @@ -232,6 +309,7 @@ jobs:
BUILD: ${{ needs.build.result }}
TEST: ${{ needs.test.result }}
DRIFT: ${{ needs.drift.result }}
MYSQL: ${{ needs.mysql.result }}
run: |
if [ "$CHANGES" = success ] && [ "$CODE" = false ]; then
echo "Docs-only change; tests skipped."
Expand All @@ -244,7 +322,7 @@ jobs:
failed=$((failed + $(grep -o '<UnitTestResult [^>]*outcome="Failed"' "$trx" | wc -l)))
done
echo "Across all lanes: $total results, $passed passed, $failed failed." | tee -a "$GITHUB_STEP_SUMMARY"
if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ]; then
echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT"
if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ] || [ "$MYSQL" != success ]; then
echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT, mysql: $MYSQL"
exit 1
fi
23 changes: 23 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,29 @@ The compatibility tests verify that the SDK, Docker images and package versions
dotnet test src\Rasa.Test\Rasa.Test.csproj --filter "FullyQualifiedName~Compatibility"
```

### Live MySQL tests

The tests in the `MySql` category run against a real MySQL server: every MySQL migration applied from empty, the same row counts as Sqlite in every table afterwards, and accounts, characters and missions written and read back through the repositories. CI runs them against MySQL 8.0 and 8.4 in the `mysql` job and leaves them out of the other test lanes. Without a server, `dotnet test` reports them as skipped.

To run them locally, start a throwaway server (`--tmpfs` keeps its data in memory, which makes the seed migrations much faster):

```powershell
docker run -d --name rasa-mysql -p 3306:3306 --tmpfs /var/lib/mysql -e MYSQL_ROOT_PASSWORD=rasa-ci mysql:8.4
```

Point the design-time factories at it with a `src\Rasa.DBL\databasesettings.env.json` (see [Database configuration](#database-configuration)) that sets `Host` `127.0.0.1`, `User` `root`, `Password` `rasa-ci` and a `TimeoutInMilliseconds` of a few minutes for each of `Auth`, `Char` and `World`; the timeout is also the command timeout, and the World seed is large. Then build, apply the migrations, and run the category with the server in `RASA_TEST_MYSQL`:

```powershell
dotnet build src\Rasa.Test
dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlAuthContext
dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlCharContext
dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlWorldContext
$env:RASA_TEST_MYSQL = "Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci"
dotnet test src\Rasa.Test --no-build --filter TestCategory=MySql
```

The tests use the database names from `databasesettings.json` and remove the rows they add. Delete `databasesettings.env.json` afterwards, or the servers you run from that build will read it too.

### Create a game user
The authentication server can be used to create a user by running a command in the terminal. The usage is: `create <email> <username> <password>`. Running this command will create a new user in the database that you can use to login with the game client.

Expand Down
2 changes: 1 addition & 1 deletion src/Rasa.Auth/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"AuthListType": "Online",
"AuthConfig": {
"Port": 2106,
"Port": 2116,
"Backlog": 100,
"ClientTimeout": 300
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,12 @@ protected override void Up(MigrationBuilder migrationBuilder)
local_client_path = table.Column<string>(type: "varchar(256)", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
confidence = table.Column<double>(type: "double unsigned", nullable: false),
reconstruction_note = table.Column<string>(type: "varchar(256)", nullable: false)
// text, not the varchar(256) this first shipped with: SeedWorldContent, next,
// writes today's Bootcamp evidence, some of whose notes are longer, and MySQL
// refused them ("Data too long"), so no MySQL world database could be built from
// empty. Databases already past this point were widened by
// WildernessAliaBranches and WildernessEvidenceCapacity.
reconstruction_note = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
Expand Down
69 changes: 69 additions & 0 deletions src/Rasa.Test/Database/LiveMySql.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MySqlConnector;

namespace Rasa.Test.Database
{
using Rasa.Configuration;
using Rasa.Configuration.ConnectionStrings;
using Rasa.Configuration.ContextSetup;
using Rasa.Context;
using Rasa.Services.DbContext;

/// <summary>
/// Contexts on a live MySQL server, for the tests in the "MySql" category. The server comes from
/// RASA_TEST_MYSQL, a MySqlConnector connection string without a database
/// ("Server=127.0.0.1;Port=3306;User ID=root;Password=..."); the database names are the ones
/// databasesettings.json gives, which is where `dotnet ef database update` applied the
/// migrations. Contexts are configured the way the servers configure them (server version
/// detection, the bounded migration lock), not with the offline setup the model tests use.
///
/// CI runs this category in its own job against mysql:8.0 and mysql:8.4 and leaves it out of
/// the other lanes; without RASA_TEST_MYSQL the tests report inconclusive. docs/setup.md
/// has the commands to run them locally.
/// </summary>
internal static class LiveMySql
{
internal const string Category = "MySql";
internal const string ServerVariable = "RASA_TEST_MYSQL";

internal static RasaDbContextBase CreateContext(Type contextType)
{
var server = Environment.GetEnvironmentVariable(ServerVariable);
if (string.IsNullOrWhiteSpace(server))
Assert.Inconclusive(
$"{ServerVariable} is not set. These tests need a MySQL server with the migrations applied; see docs/setup.md.");

var builder = new MySqlConnectionStringBuilder(server);
using var settings = JsonDocument.Parse(File.ReadAllText(
Path.Combine(AppContext.BaseDirectory, "databasesettings.json")));
var databases = settings.RootElement.GetProperty("Databases");

DatabaseConnectionConfiguration Connection(string name) => new()
{
Host = builder.Server,
Port = builder.Port,
User = builder.UserID,
Password = builder.Password,
Database = databases.GetProperty(name).GetProperty("Database").GetString(),
TimeoutInMilliseconds = 30000
};

var options = Options.Create(new DatabaseConfiguration
{
Provider = "MySql",
Auth = Connection("Auth"),
Char = Connection("Char"),
World = Connection("World")
});
return (RasaDbContextBase)Activator.CreateInstance(
contextType,
options,
new MySqlDbContextConfigurationService(new MySqlConnectionStringFactory()),
new MySqlDbContextPropertyModifier());
}
}
}
Loading
Loading