Refused JSON contracts built by reflection and checked all of them in the trimmed build (#1152)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-03 18:29:50 +01:00
1 parent f864359290
commit 0b42c0288e
23 files changed
+535 -99

No files matched your search

@@ -0,0 +1,106 @@
using AIStudio.Tests.Models.Generation;
using Microsoft.CodeAnalysis;
using SourceCodeRules.UsageAnalyzers;
namespace AIStudio.Tests.Analyzers;
/// <summary>
/// Checks that every way into JSON contracts built by reflection is refused while compiling.
/// </summary>
/// <remarks>
/// The pre-release v26.10.1 failed because a published build lacked what reflection needed for the JSON of
/// AI Studio. Since then, the options of AI Studio name a JsonSerializerContext, whose contracts are generated
/// while compiling. A single call without options brings reflection back, and only a published build shows
/// it, so MWAIS0015 turns such a call into a build error. These tests say which code it refuses and which it
/// leaves alone.
/// </remarks>
[TestFixture]
public sealed class JsonSourceGenerationAnalyzerTests
{
[TestCase("JsonSerializer.Serialize(meeting)", "JsonSerializer.Serialize gets no options", TestName = "Writing without options")]
[TestCase("JsonSerializer.Serialize(meeting, options: null)", "JsonSerializer.Serialize gets no options", TestName = "Writing with options which are null")]
[TestCase("JsonSerializer.Deserialize<Meeting>(json)", "JsonSerializer.Deserialize gets no options", TestName = "Reading without options")]
[TestCase("element.Deserialize<Meeting>()", "JsonSerializer.Deserialize gets no options", TestName = "Reading a JSON element without options")]
[TestCase("client.GetFromJsonAsync<Meeting>(\"/meeting\")", "HttpClientJsonExtensions.GetFromJsonAsync gets no options", TestName = "Reading a web answer without options")]
[TestCase("client.PostAsJsonAsync(\"/meeting\", meeting)", "HttpClientJsonExtensions.PostAsJsonAsync gets no options", TestName = "Sending a web request without options")]
[TestCase("content.ReadFromJsonAsync<Meeting>()", "HttpContentJsonExtensions.ReadFromJsonAsync gets no options", TestName = "Reading web content without options")]
[TestCase("JsonContent.Create(meeting)", "JsonContent.Create gets no options", TestName = "Creating web content without options")]
[TestCase("JsonSerializer.Serialize(meeting, JsonSerializerOptions.Default)", "JsonSerializerOptions.Default builds every contract by reflection", TestName = "The default options")]
[TestCase("JsonSerializerOptions.Web", "JsonSerializerOptions.Web builds every contract by reflection", TestName = "The options for the web")]
[TestCase("new DefaultJsonTypeInfoResolver()", "DefaultJsonTypeInfoResolver builds contracts by reflection", TestName = "The resolver which uses reflection")]
[TestCase("new JsonSerializerOptions { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }", "DefaultJsonTypeInfoResolver builds contracts by reflection", TestName = "Options with the resolver which uses reflection")]
[TestCase("new JsonSerializerOptions { WriteIndented = true }", "These options name no TypeInfoResolver", TestName = "Options without a resolver")]
[TestCase("new JsonSerializerOptions(JsonSerializerDefaults.Web)", "These options name no TypeInfoResolver", TestName = "Options for the web without a resolver")]
[TestCase("new JsonSerializerOptions { TypeInfoResolver = null }", "These options name no TypeInfoResolver", TestName = "Options with a resolver which is null")]
public async Task AWayIntoReflectionIsRefused(string expression, string expectedMessage)
{
var reported = await AnalyzeAsync(expression);
using (Assert.EnterMultipleScope())
{
Assert.That(reported, Has.Count.EqualTo(1));
Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0015"));
Assert.That(reported.FirstOrDefault()?.GetMessage(), Does.StartWith(expectedMessage));
}
}
[Test]
public async Task OptionsCreatedByATargetTypedNewWithoutAResolverAreRefused()
{
var reported = await AnalyzeAsync("OTHER_OPTIONS", "private static readonly JsonSerializerOptions OTHER_OPTIONS = new() { WriteIndented = true };");
using (Assert.EnterMultipleScope())
{
Assert.That(reported, Has.Count.EqualTo(1));
Assert.That(reported.FirstOrDefault()?.GetMessage(), Does.StartWith("These options name no TypeInfoResolver"));
}
}
[TestCase("JsonSerializer.Serialize(meeting, OPTIONS)", TestName = "Writing with options")]
[TestCase("JsonSerializer.Serialize(meeting, contract)", TestName = "Writing with the contract")]
[TestCase("element.Deserialize(contract)", TestName = "Reading a JSON element with the contract")]
[TestCase("client.GetFromJsonAsync<Meeting>(\"/meeting\", OPTIONS)", TestName = "Reading a web answer with options")]
[TestCase("content.ReadFromJsonAsync(contract)", TestName = "Reading web content with the contract")]
[TestCase("JsonContent.Create(meeting, contract)", TestName = "Creating web content with the contract")]
[TestCase("new JsonSerializerOptions { WriteIndented = true, TypeInfoResolver = resolver }", TestName = "Options with a resolver")]
[TestCase("new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = resolver }", TestName = "Options for the web with a resolver")]
[TestCase("JsonNode.Parse(json)", TestName = "A tree of JSON nodes")]
public async Task JsonWithGeneratedContractsIsLeftAlone(string expression)
{
var reported = await AnalyzeAsync(expression);
Assert.That(reported, Is.Empty);
}
private static async Task<IReadOnlyList<Diagnostic>> AnalyzeAsync(string expression, string otherMembers = "")
{
var source =
$$"""
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace Sample;
public sealed class Meeting
{
public string Location { get; set; } = "Cologne";
}
public static class MeetingJson
{
private static readonly JsonSerializerOptions OPTIONS = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
{{otherMembers}}
public static object? Use(Meeting meeting, string json, JsonElement element, HttpClient client, HttpContent content, JsonTypeInfo<Meeting> contract, IJsonTypeInfoResolver resolver) => {{expression}};
}
""";
return await CompilationHarness.AnalyzeAsync(source, new JsonSourceGenerationAnalyzer());
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Text.Json.Nodes;
using AIStudio.Components;
namespace AIStudio.Tests.Components;
/// <summary>
/// Checks how the tree of a tool result in the chat writes its keys and values.
/// </summary>
/// <remarks>
/// The tree writes each key and each value as JSON. Escaping for a web page, as the default encoder of
/// System.Text.Json does, would show every umlaut and every quotation mark as a code of six characters,
/// although Blazor encodes the text for the page anyway. A value parsed from JSON goes through the same
/// writer as one created in code, so both get checked.
/// </remarks>
[TestFixture]
public sealed class JsonTreeViewTests
{
[TestCase("Größe der Übersicht")]
[TestCase("if (a < b && c > d) return 'x' + y;")]
[TestCase("日本語のテキスト")]
public void ValuesAppearAsWritten(string text)
{
var parsed = JsonNode.Parse(new JsonObject { ["text_content"] = text }.ToJsonString())!["text_content"];
using (Assert.EnterMultipleScope())
{
Assert.That(JsonTreeView.FormatValue(JsonValue.Create(text)), Is.EqualTo($"\"{text}\""), "A value created in code.");
Assert.That(JsonTreeView.FormatValue(parsed), Is.EqualTo($"\"{text}\""), "A value parsed from JSON.");
}
}
[TestCase("Größe")]
[TestCase("a < b && c > d")]
public void KeysAppearAsWritten(string key) => Assert.That(JsonTreeView.FormatKey(key), Is.EqualTo($"\"{key}\""));
[Test]
public void WhatJsonHasToEscapeStaysEscaped()
{
const string TEXT = "A \"quoted\" line\nand a backslash \\ at its end.";
using (Assert.EnterMultipleScope())
{
Assert.That(JsonNode.Parse(JsonTreeView.FormatValue(JsonValue.Create(TEXT)))?.GetValue<string>(), Is.EqualTo(TEXT), "A value.");
Assert.That(JsonNode.Parse(JsonTreeView.FormatKey(TEXT))?.GetValue<string>(), Is.EqualTo(TEXT), "A key.");
}
}
}
+10 -14
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Provider.Groq;
using AIStudio.Provider.OpenRouter;
using AIStudio.Provider.Requesty;
@@ -19,17 +20,12 @@ namespace AIStudio.Tests.Provider;
/// below are shortened answers of the real routes, so that a rename is caught here rather than by
/// somebody wondering why their window never shows up.
///
/// The options mirror what the providers deserialize with: names in snake case, which is what makes
/// the renaming attributes necessary in the first place.
/// The answers are read with the options of the providers themselves: names in snake case, which is
/// what makes the renaming attributes necessary in the first place.
/// </remarks>
[TestFixture]
public sealed class ModelListMetadataTests
{
private static readonly JsonSerializerOptions AS_THE_PROVIDERS_READ_IT = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
[Test]
// ReSharper disable once InconsistentNaming
public void VLLMStatesTheWindowItWasStartedWith()
@@ -41,7 +37,7 @@ public sealed class ModelListMetadataTests
{ "id": "Qwen/Qwen3-32B", "object": "model", "owned_by": "vllm", "max_model_len": 32768 }
]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
Assert.That(response.Data, Is.Not.Null);
using (Assert.EnterMultipleScope())
@@ -63,7 +59,7 @@ public sealed class ModelListMetadataTests
"object": "list",
"data": [ { "id": "gemma3:1b", "object": "model" } ]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
Assert.That(response.Data, Is.Not.Null);
Assert.That(response.Data![0].ContextWindowTokens, Is.Null);
@@ -84,7 +80,7 @@ public sealed class ModelListMetadataTests
}
]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
using (Assert.EnterMultipleScope())
{
@@ -101,7 +97,7 @@ public sealed class ModelListMetadataTests
"object": "list",
"data": [ { "id": "llama-3.3-70b-versatile", "object": "model", "context_window": 131072 } ]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(131_072));
}
@@ -114,7 +110,7 @@ public sealed class ModelListMetadataTests
"object": "list",
"data": [ { "id": "openai/gpt-4o-mini", "object": "model", "api": "chat", "context_window": 128000, "max_output_tokens": 16384 } ]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(128_000));
}
@@ -127,7 +123,7 @@ public sealed class ModelListMetadataTests
"object": "list",
"data": [ { "id": "mistral-large-latest", "object": "model", "created": 1700000000, "owned_by": "mistralai", "max_context_length": 131072 } ]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
Assert.That(response.Data[0].ContextWindowTokens, Is.EqualTo(131_072));
}
@@ -147,7 +143,7 @@ public sealed class ModelListMetadataTests
}
]
}
""", AS_THE_PROVIDERS_READ_IT);
""", ProviderJsonOptions.OPTIONS);
Assert.That(response.Data[0].Providers, Is.Not.Null);
Assert.That(response.Data[0].Providers![0].ContextWindowTokens, Is.EqualTo(64_000));
@@ -25,11 +25,6 @@ namespace AIStudio.Tests.Provider;
[TestFixture]
public sealed class SelfHostedModelListTests
{
private static readonly JsonSerializerOptions AS_THE_PROVIDERS_READ_IT = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
/// <summary>
/// What "GET /v1/models" answers on a local Ollama, shortened to the fields it sends.
/// </summary>
@@ -51,7 +46,7 @@ public sealed class SelfHostedModelListTests
private static IReadOnlyList<Model> TheModelsTheEngineListed()
{
var response = JsonSerializer.Deserialize<SelfHostedModelsResponse>(WHAT_A_LOCAL_OLLAMA_ANSWERS, AS_THE_PROVIDERS_READ_IT);
var response = JsonSerializer.Deserialize<SelfHostedModelsResponse>(WHAT_A_LOCAL_OLLAMA_ANSWERS, ProviderJsonOptions.OPTIONS);
Assert.That(response.Data, Is.Not.Null, "The answer has to be readable before anything can be sorted out of it.");
return response.Data!
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AIStudio.TrimSmokeTest;
namespace AIStudio.Tests.Serialization;
/// <summary>
/// Checks that all options of the app get their contracts from source generation, never from reflection.
/// </summary>
/// <remarks>
/// MWAIS0015 refuses options without a TypeInfoResolver while compiling, but cannot tell what the resolver does.
/// Only contexts throw for a type they do not know. Any other resolver, e.g., a DefaultJsonTypeInfoResolver which
/// a helper adds to the chain, builds the missing contract by reflection instead, and in a published build, the
/// code it needs for that may be gone.
/// </remarks>
[TestFixture]
public sealed class JsonOptionsResolverTests
{
private static IEnumerable<TestCaseData> AllOptions()
{
TestHost.PrepareStaticState();
return AppJsonOptions.FindAll().Select(options => new TestCaseData(options.Value).SetArgDisplayNames(options.Key));
}
[TestCaseSource(nameof(AllOptions))]
public void TheOptionsResolveWithContextsOnly(JsonSerializerOptions options)
{
using (Assert.EnterMultipleScope())
{
Assert.That(options.TypeInfoResolverChain, Is.Not.Empty);
Assert.That(options.TypeInfoResolverChain, Is.All.InstanceOf<JsonSerializerContext>());
}
}
[Test]
public void FieldsAndPropertiesOfTheAppAreFound()
{
TestHost.PrepareStaticState();
var names = AppJsonOptions.FindAll().Keys.ToList();
using (Assert.EnterMultipleScope())
{
Assert.That(names, Does.Contain("SettingsManager.JSON_OPTIONS"));
Assert.That(names, Does.Contain("VisualBriefingJson.Canonical"));
Assert.That(names, Has.None.Contains("k__BackingField"));
}
}
}
+7
View File
@@ -45,6 +45,13 @@
<ProjectReference Include="..\SourceGeneratedMappings\SourceGeneratedMappings.csproj" />
</ItemGroup>
<!--
Compiled here as well, so that the tests check the same options of the app which the trim smoke test finds.
-->
<ItemGroup>
<Compile Include="..\TrimSmokeTest\AppJsonOptions.cs" Link="Serialization\AppJsonOptions.cs" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
+2 -1
View File
@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using AIStudio.Tools.Validation;
namespace AIStudio.Tests.Tools;
@@ -122,7 +123,7 @@ public class FileTypesTests
public void DialogFilterMatchesTheRuntimeContract()
{
// The struct FileTypeFilter in runtime/src/file_actions.rs knows exactly these two fields:
var json = JsonSerializer.Serialize(FileTypes.ToFileDialogFilter(FileTypes.JSON), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
var json = JsonSerializer.Serialize(FileTypes.ToFileDialogFilter(FileTypes.JSON), RustService.JSON_OPTIONS);
Assert.That(json, Is.EqualTo("""{"filter_name":"JSON","filter_extensions":["json"]}"""));
}
+4 -13
View File
@@ -11,27 +11,18 @@ namespace AIStudio.Tests.Tools;
/// <remarks>
/// ShareFileResponse in runtime/src/share_sheet.rs names the outcome in snake case. A name which the
/// app does not know would not fail anywhere: the enum converter falls back to the first member, and
/// a share sheet which did not respond would go unnoticed again. The options mirror what RustService
/// deserializes with.
/// a share sheet which did not respond would go unnoticed again. The answers are read with the options
/// of RustService itself.
/// </remarks>
[TestFixture]
public sealed class ShareFileResponseTests
{
private static readonly JsonSerializerOptions AS_THE_RUST_SERVICE_READS_IT = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters =
{
new RustEnumConverter(),
},
};
[TestCase("opened", ShareSheetOutcome.OPENED)]
[TestCase("failed", ShareSheetOutcome.FAILED)]
[TestCase("unresponsive", ShareSheetOutcome.UNRESPONSIVE)]
public void TheAppReadsEveryOutcomeOfTheRuntime(string outcome, ShareSheetOutcome expectedOutcome)
{
var response = JsonSerializer.Deserialize<ShareFileResponse>($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", AS_THE_RUST_SERVICE_READS_IT);
var response = JsonSerializer.Deserialize<ShareFileResponse>($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", RustService.JSON_OPTIONS);
Assert.That(response, Is.Not.Null);
using (Assert.EnterMultipleScope())
@@ -44,7 +35,7 @@ public sealed class ShareFileResponseTests
[Test]
public void AnUnknownOutcomeNeverCountsAsAnOpenedShareSheet()
{
var response = JsonSerializer.Deserialize<ShareFileResponse>("""{"outcome":"vanished","issue":""}""", AS_THE_RUST_SERVICE_READS_IT);
var response = JsonSerializer.Deserialize<ShareFileResponse>("""{"outcome":"vanished","issue":""}""", RustService.JSON_OPTIONS);
Assert.That(response?.Outcome, Is.EqualTo(ShareSheetOutcome.UNKNOWN));
}