Skip to content

Commit 37d78e9

Browse files
authored
Add compiler compatibility test suite for anonymous records with MSBuild-generated build verification and comprehensive documentation (#18913)
1 parent 36645e1 commit 37d78e9

File tree

9 files changed

+584
-15
lines changed

9 files changed

+584
-15
lines changed

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,13 @@ TestResults/*.trx
148148
StandardOutput.txt
149149
StandardError.txt
150150
**/TestResults/
151+
152+
# CompilerCompat test project generated files
153+
tests/projects/CompilerCompat/**/nuget.config
154+
tests/projects/CompilerCompat/**/global.json
155+
tests/projects/CompilerCompat/**/*.deps.json
156+
tests/projects/CompilerCompat/**/*.xml
157+
tests/projects/CompilerCompat/local-nuget-packages/
158+
tests/projects/CompilerCompat/lib-output-*/
159+
tests/projects/CompilerCompat/**/bin/
160+
tests/projects/CompilerCompat/**/obj/

azure-pipelines-PR.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,8 @@ stages:
749749
env:
750750
FSHARP_EXPERIMENTAL_FEATURES: $(_experimental_flag)
751751
displayName: End to end build tests
752+
- script: .\eng\common\dotnet.cmd fsi .\tests\FSharp.Compiler.ComponentTests\CompilerCompatibilityTests.fsx
753+
displayName: Compiler compatibility tests
752754

753755
# Up-to-date - disabled due to it being flaky
754756
#- job: UpToDate_Windows
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
#!/usr/bin/env dotnet fsi
2+
3+
(*
4+
# F# Compiler Compatibility Test Suite
5+
6+
## What This Does
7+
8+
This test suite verifies **binary compatibility** of F# anonymous records across different F# compiler versions. It is meant as a place to grow by any other testing use case that wants to verify pickling handover, anon records are just the first pilot. For expanding this, just add more code to lib+app.
9+
It ensures that libraries and applications compiled with different F# compilers can interoperate correctly,
10+
focusing on the binary serialization format (pickle format) of anonymous records.
11+
12+
The test suite exercises three critical compatibility scenarios:
13+
1. **Baseline**: Both library and application built with the local (development) compiler
14+
2. **Forward Compatibility**: Library built with SDK compiler, application with local compiler
15+
3. **Backward Compatibility**: Library built with local compiler, application with SDK compiler
16+
17+
## Why This Matters - Binary Compatibility of Pickle Format
18+
19+
F# uses a binary serialization format (pickle format) to encode type information and metadata for all signatures and also optimization related data.
20+
21+
**The Problem**: When the F# compiler changes, the pickle format can evolve. If not carefully managed, this can break binary compatibility:
22+
- A library compiled with F# 9.0 might generate anonymous records that F# 8.0 can't read
23+
- Breaking changes in the pickle format can cause compilation failures or incorrect behavior
24+
- Even minor compiler changes can inadvertently alter binary serialization
25+
26+
**Why Anonymous Records**: They just happen to be the fist use case:
27+
28+
This test suite acts as a **regression guard** to catch any changes that would break binary compatibility,
29+
ensuring the F# ecosystem remains stable as the compiler evolves.
30+
31+
## How It Works
32+
33+
### 1. MSBuild Integration
34+
35+
The test controls which F# compiler is used through MSBuild properties:
36+
37+
**Local Compiler** (`LoadLocalFSharpBuild=True`):
38+
- Uses the freshly-built compiler from `artifacts/bin/fsc`
39+
- Configured via `UseLocalCompiler.Directory.Build.props` in repo root
40+
- Allows testing bleeding-edge compiler changes
41+
42+
**SDK Compiler** (`LoadLocalFSharpBuild=False` or not set):
43+
- Uses the F# compiler from the installed .NET SDK
44+
- Represents what users have in production
45+
46+
### 2. Global.json Management
47+
48+
For testing specific .NET versions, the suite dynamically creates `global.json` files:
49+
50+
```json
51+
{
52+
"sdk": {
53+
"version": "9.0.300",
54+
"rollForward": "latestMinor"
55+
}
56+
}
57+
```
58+
59+
This allows testing compatibility with specific SDK versions (like .NET 9) without requiring
60+
hardcoded installations. The `rollForward: latestMinor` policy provides flexibility across patch versions.
61+
62+
### 3. Build-Time Verification
63+
64+
Each project generates a `BuildInfo.fs` file at build time using MSBuild targets:
65+
66+
```xml
67+
<Target Name="GenerateLibBuildInfo" BeforeTargets="BeforeCompile">
68+
<WriteLinesToFile File="LibBuildInfo.fs"
69+
Lines="module LibBuildInfo =
70+
let sdkVersion = &quot;$(NETCoreSdkVersion)&quot;
71+
let fsharpCompilerPath = &quot;$(FscToolPath)\$(FscToolExe)&quot;
72+
let dotnetFscCompilerPath = &quot;$(DotnetFscCompilerPath)&quot;
73+
let isLocalBuild = $(IsLocalBuildValue)" />
74+
</Target>
75+
```
76+
77+
This captures actual build-time information, allowing tests to verify which compiler was actually used.
78+
79+
### 4. Test Flow
80+
81+
For each scenario:
82+
1. **Clean** previous builds to ensure isolation
83+
2. **Pack** the library with specified compiler (creates NuGet package)
84+
3. **Build** the application with specified compiler, referencing the packed library
85+
4. **Run** the application and verify:
86+
- Anonymous records work correctly across compiler boundaries
87+
- Build info confirms correct compilers were used
88+
- No runtime errors or data corruption
89+
90+
### 5. Anonymous Record Testing
91+
92+
The library (`CompilerCompatLib`) exposes APIs using anonymous records:
93+
- Simple anonymous records: `{| X = 42; Y = "hello" |}`
94+
- Nested anonymous records: `{| Simple = {| A = 1 |}; List = [...] |}`
95+
- Complex structures mixing anonymous records with other F# types
96+
97+
The application (`CompilerCompatApp`) consumes these APIs and validates that:
98+
- Field access works correctly
99+
- Nested structures are properly preserved
100+
- Type information matches expectations
101+
102+
This ensures the binary pickle format remains compatible even when compilers change.
103+
104+
## Running the Tests
105+
106+
**Standalone script:**
107+
```bash
108+
dotnet fsi tests/FSharp.Compiler.ComponentTests/CompilerCompatibilityTests.fsx
109+
```
110+
111+
## Extending the Test Suite
112+
113+
To add more compatibility tests:
114+
1. Add new functions to `CompilerCompatLib/Library.fs`
115+
2. Add corresponding validation in `CompilerCompatApp/Program.fs`
116+
3. The existing test infrastructure will automatically verify compatibility
117+
118+
*)
119+
120+
// Standalone F# script to test compiler compatibility across different F# SDK versions
121+
// Can be run with: dotnet fsi CompilerCompatibilityTests.fsx
122+
123+
open System
124+
open System.IO
125+
open System.Diagnostics
126+
127+
// Configuration
128+
let compilerConfiguration = "Release"
129+
let repoRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../.."))
130+
let projectsPath = Path.Combine(__SOURCE_DIRECTORY__, "../projects/CompilerCompat")
131+
let libProjectPath = Path.Combine(projectsPath, "CompilerCompatLib")
132+
let appProjectPath = Path.Combine(projectsPath, "CompilerCompatApp")
133+
134+
// Test scenarios: (libCompiler, appCompiler, description)
135+
let testScenarios = [
136+
("local", "local", "Baseline - Both library and app built with local compiler")
137+
("latest", "local", "Forward compatibility - Library with SDK, app with local")
138+
("local", "latest", "Backward compatibility - Library with local, app with SDK")
139+
("latest", "latest", "SDK only - Both library and app built with latest SDK")
140+
("net9", "local", "Net9 forward compatibility - Library with .NET 9 SDK, app with local")
141+
("local", "net9", "Net9 backward compatibility - Library with local, app with .NET 9 SDK")
142+
]
143+
144+
// Helper functions
145+
let runCommand (command: string) (args: string) (workingDir: string) (envVars: (string * string) list) =
146+
let psi = ProcessStartInfo()
147+
psi.FileName <- command
148+
psi.Arguments <- args
149+
psi.WorkingDirectory <- workingDir
150+
psi.RedirectStandardOutput <- true
151+
psi.RedirectStandardError <- true
152+
psi.UseShellExecute <- false
153+
psi.CreateNoWindow <- true
154+
155+
// Set environment variables
156+
for (key, value) in envVars do
157+
psi.EnvironmentVariables.[key] <- value
158+
159+
use p = new Process()
160+
p.StartInfo <- psi
161+
162+
if not (p.Start()) then
163+
failwith $"Failed to start process: {command} {args}"
164+
165+
let stdout = p.StandardOutput.ReadToEnd()
166+
let stderr = p.StandardError.ReadToEnd()
167+
p.WaitForExit()
168+
169+
if p.ExitCode <> 0 then
170+
printfn "Command failed: %s %s" command args
171+
printfn "Working directory: %s" workingDir
172+
printfn "Exit code: %d" p.ExitCode
173+
printfn "Stdout: %s" stdout
174+
printfn "Stderr: %s" stderr
175+
failwith $"Command exited with code {p.ExitCode}"
176+
177+
stdout
178+
179+
let cleanDirectory path =
180+
if Directory.Exists(path) then
181+
Directory.Delete(path, true)
182+
183+
let cleanBinObjDirectories projectPath =
184+
cleanDirectory (Path.Combine(projectPath, "bin"))
185+
cleanDirectory (Path.Combine(projectPath, "obj"))
186+
let libBuildInfo = Path.Combine(projectPath, "LibBuildInfo.fs")
187+
let appBuildInfo = Path.Combine(projectPath, "AppBuildInfo.fs")
188+
if File.Exists(libBuildInfo) then File.Delete(libBuildInfo)
189+
if File.Exists(appBuildInfo) then File.Delete(appBuildInfo)
190+
191+
let manageGlobalJson compilerVersion enable =
192+
let globalJsonPath = Path.Combine(projectsPath, "global.json")
193+
if compilerVersion = "net9" then
194+
if enable && not (File.Exists(globalJsonPath) && File.ReadAllText(globalJsonPath).Contains("9.0.0")) then
195+
printfn " Enabling .NET 9 SDK via global.json..."
196+
let globalJsonContent = """{
197+
"sdk": {
198+
"version": "9.0.0",
199+
"rollForward": "latestMajor"
200+
},
201+
"msbuild-sdks": {
202+
"Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.25509.1"
203+
}
204+
}"""
205+
File.WriteAllText(globalJsonPath, globalJsonContent)
206+
elif not enable && File.Exists(globalJsonPath) then
207+
printfn " Removing global.json..."
208+
File.Delete(globalJsonPath)
209+
210+
let packProject projectPath compilerVersion outputDir =
211+
let useLocal = (compilerVersion = "local")
212+
// Use timestamp-based version to ensure fresh package each time
213+
let timestamp = DateTime.Now.ToString("HHmmss")
214+
let envVars = [
215+
("LoadLocalFSharpBuild", if useLocal then "True" else "False")
216+
("LocalFSharpCompilerConfiguration", compilerConfiguration)
217+
("PackageVersion", $"1.0.{timestamp}")
218+
]
219+
220+
// Manage global.json for net9 compiler
221+
manageGlobalJson compilerVersion true
222+
223+
printfn " Packing library with %s compiler..." compilerVersion
224+
let projectFile = Path.Combine(projectPath, "CompilerCompatLib.fsproj")
225+
let output = runCommand "dotnet" $"pack \"{projectFile}\" -c {compilerConfiguration} -o \"{outputDir}\"" projectPath envVars
226+
227+
// Clean up global.json after pack
228+
manageGlobalJson compilerVersion false
229+
230+
output |> ignore
231+
232+
let buildApp projectPath compilerVersion =
233+
let useLocal = (compilerVersion = "local")
234+
let envVars = [
235+
("LoadLocalFSharpBuild", if useLocal then "True" else "False")
236+
("LocalFSharpCompilerConfiguration", compilerConfiguration)
237+
]
238+
239+
// Manage global.json for net9 compiler
240+
manageGlobalJson compilerVersion true
241+
242+
printfn " Building app with %s compiler..." compilerVersion
243+
let projectFile = Path.Combine(projectPath, "CompilerCompatApp.fsproj")
244+
245+
// First restore with force to get fresh NuGet packages
246+
runCommand "dotnet" $"restore \"{projectFile}\" --force --no-cache" projectPath envVars |> ignore
247+
248+
// Then build
249+
runCommand "dotnet" $"build \"{projectFile}\" -c {compilerConfiguration} --no-restore" projectPath envVars
250+
|> ignore
251+
252+
// Clean up global.json after build
253+
manageGlobalJson compilerVersion false
254+
255+
let runApp() =
256+
let appDll = Path.Combine(appProjectPath, "bin", compilerConfiguration, "net8.0", "CompilerCompatApp.dll")
257+
printfn " Running app..."
258+
// Use --roll-forward Major to allow running net8.0 app on net10.0 runtime
259+
let envVars = [
260+
("DOTNET_ROLL_FORWARD", "Major")
261+
]
262+
let output = runCommand "dotnet" $"\"{appDll}\"" appProjectPath envVars
263+
output
264+
265+
let extractValue (sectionHeader: string) (searchPattern: string) (lines: string array) =
266+
lines
267+
|> Array.tryFindIndex (fun (l: string) -> l.StartsWith(sectionHeader))
268+
|> Option.bind (fun startIdx ->
269+
lines
270+
|> Array.skip (startIdx + 1)
271+
|> Array.take (min 10 (lines.Length - startIdx - 1))
272+
|> Array.tryFind (fun (l: string) -> l.Contains(searchPattern)))
273+
274+
let verifyOutput libCompilerVersion appCompilerVersion (output: string) =
275+
let lines = output.Split('\n') |> Array.map (fun (s: string) -> s.Trim())
276+
277+
// Check for success message
278+
if not (Array.exists (fun (l: string) -> l.Contains("SUCCESS: All compiler compatibility tests passed")) lines) then
279+
failwith "App did not report success"
280+
281+
// Extract build info
282+
let getBool section pattern =
283+
extractValue section pattern lines
284+
|> Option.map (fun l -> l.Contains("true"))
285+
|> Option.defaultValue false
286+
287+
let libIsLocal = getBool "Library Build Info:" "Is Local Build:"
288+
let appIsLocal = getBool "Application Build Info:" "Is Local Build:"
289+
290+
// Verify - both "latest" and "net9" should result in isLocalBuild=false
291+
let expectedLibIsLocal = (libCompilerVersion = "local")
292+
let expectedAppIsLocal = (appCompilerVersion = "local")
293+
294+
if libIsLocal <> expectedLibIsLocal then
295+
failwith $"Library: expected isLocalBuild={expectedLibIsLocal} for '{libCompilerVersion}', but got {libIsLocal}"
296+
297+
if appIsLocal <> expectedAppIsLocal then
298+
failwith $"App: expected isLocalBuild={expectedAppIsLocal} for '{appCompilerVersion}', but got {appIsLocal}"
299+
300+
printfn " ✓ Build info verification passed"
301+
302+
// Main test execution
303+
let runTest (libCompiler, appCompiler, description) =
304+
printfn "\n=== Test: %s ===" description
305+
printfn "Library compiler: %s, App compiler: %s" libCompiler appCompiler
306+
307+
try
308+
// Clean previous builds
309+
cleanBinObjDirectories libProjectPath
310+
cleanBinObjDirectories appProjectPath
311+
312+
// Create local NuGet directory
313+
let localNuGetDir = Path.Combine(projectsPath, "local-nuget-packages")
314+
cleanDirectory localNuGetDir
315+
Directory.CreateDirectory(localNuGetDir) |> ignore
316+
317+
// Create nuget.config for app
318+
let nugetConfig = Path.Combine(appProjectPath, "nuget.config")
319+
let nugetConfigContent = $"""<?xml version="1.0" encoding="utf-8"?>
320+
<configuration>
321+
<packageSources>
322+
<clear />
323+
<add key="local-packages" value="{localNuGetDir}" />
324+
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
325+
</packageSources>
326+
</configuration>"""
327+
File.WriteAllText(nugetConfig, nugetConfigContent)
328+
329+
// Pack library
330+
packProject libProjectPath libCompiler localNuGetDir
331+
332+
// Build and run app
333+
buildApp appProjectPath appCompiler
334+
let output = runApp()
335+
336+
// Verify
337+
verifyOutput libCompiler appCompiler output
338+
339+
printfn "✓ PASSED: %s" description
340+
true
341+
with ex ->
342+
printfn "✗ FAILED: %s" description
343+
printfn "Error: %s" ex.Message
344+
false
345+
346+
// Run all tests
347+
printfn "F# Compiler Compatibility Test Suite"
348+
printfn "======================================"
349+
350+
let results = testScenarios |> List.map runTest
351+
352+
let passed = results |> List.filter id |> List.length
353+
let total = results |> List.length
354+
355+
printfn "\n======================================"
356+
printfn "Results: %d/%d tests passed" passed total
357+
358+
if passed = total then
359+
printfn "All tests PASSED ✓"
360+
exit 0
361+
else
362+
printfn "Some tests FAILED ✗"
363+
exit 1

0 commit comments

Comments
 (0)