diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c119d89a..0be83d8b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -6,11 +6,15 @@ on: pull_request: branches: [ dev ] +env: + PUPPETEER_CACHE_DIR: ${{ github.workspace }}/.cache/verso-browser + PLOTLY_VERSO_TEST_ARTIFACTS: ${{ github.workspace }}/tests/VersoTests/artifacts/browser jobs: build-and-test-linux: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -21,13 +25,32 @@ jobs: 10.x.x - name: make script executable run: chmod u+x build.sh + - name: Install Chromium libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends fonts-liberation libasound2t64 libatk-bridge2.0-0t64 libgbm1 libgtk-3-0t64 libnss3 libxss1 + - uses: actions/cache@v4 + with: + path: .cache/verso-browser + key: verso-chromium-${{ runner.os }}-puppeteersharp-25.15.0 + - name: Provision pinned Chromium + run: ./build.sh PrepareVersoBrowser - name: Build and test working-directory: ./ run: ./build.sh runtestsall + - name: Upload test diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tests-linux + path: | + **/TestResults/** + tests/VersoTests/artifacts/browser/** build-and-test-windows: runs-on: windows-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -36,6 +59,53 @@ jobs: with: dotnet-version: | 10.x.x + - uses: actions/cache@v4 + with: + path: .cache/verso-browser + key: verso-chromium-${{ runner.os }}-puppeteersharp-25.15.0 + - name: Provision pinned Chromium + run: ./build.cmd PrepareVersoBrowser - name: Build and test working-directory: ./ run: ./build.cmd runtestsall + - name: Upload test diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tests-windows + path: | + **/TestResults/** + tests/VersoTests/artifacts/browser/** + + verso-package-integration: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.x.x + - name: Install Chromium libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends fonts-liberation libasound2t64 libatk-bridge2.0-0t64 libgbm1 libgtk-3-0t64 libnss3 libxss1 + - uses: actions/cache@v4 + with: + path: .cache/verso-browser + key: verso-chromium-${{ runner.os }}-puppeteersharp-25.15.0 + - name: Test local packages in Verso 1.2.3 + run: | + chmod u+x build.sh + ./build.sh RunVersoIntegrationTests + - name: Upload host diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: verso-host-integration + path: | + tests/VersoTests/artifacts/packages.json + tests/VersoTests/artifacts/runs/*/*.log + tests/VersoTests/artifacts/runs/*/*.json + tests/VersoTests/artifacts/runs/*/*.html + tests/VersoTests/artifacts/runs/*/*.png + tests/VersoTests/artifacts/runs/*/*.verso diff --git a/.gitignore b/.gitignore index de190950..f612cb31 100644 --- a/.gitignore +++ b/.gitignore @@ -170,6 +170,7 @@ packages/ # Test results produced by build TestResults.xml +**/TestResults/ # Nuget outputs nuget/*.nupkg @@ -197,3 +198,6 @@ temp/gh-pages /plotly-gource.x264.avi /plotly-gource.ppm /tests/InteractiveTests/pkg +/tests/VersoTests/pkg +/tests/VersoTests/artifacts/ +/.cache/verso-browser/ diff --git a/AGENTS.md b/AGENTS.md index a021df45..33c0709a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,8 +90,12 @@ For faster local iteration, prefer the FAKE `*Fast` test targets. These skip the ./build.cmd RunTestsExtensionLibsFast ./build.cmd RunImageExportTestsFast ./build.cmd RunCSharpTestsFast +./build.cmd RunVersoTestsFast +./build.cmd RunVersoBrowserTestsFast ``` +Verso's packaged CLI/web-host smoke uses `RunVersoIntegrationTests` separately from the regular unit/browser aggregate. `PrepareVersoBrowser` provisions the pinned Chromium revision. See `tests/README.md` for cache isolation and the VS Code release smoke. + Use the fast targets during implementation, but **always run a full `./build.cmd runTestsAll` before committing changes** so the clean end-to-end pipeline is exercised at least once. ### Writing tests diff --git a/Plotly.NET.TestSuite.sln b/Plotly.NET.TestSuite.sln index 45ed8085..8e4bb6f7 100644 --- a/Plotly.NET.TestSuite.sln +++ b/Plotly.NET.TestSuite.sln @@ -28,6 +28,28 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".project", ".project", "{F8 tests\README.md = tests\README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExtensionLibsTests", "ExtensionLibsTests", "{B49D0B3A-AF90-AE88-9F71-7A7FA5B38005}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VersoTests", "tests\ExtensionLibsTests\VersoTests\VersoTests.fsproj", "{EA82753E-7A5C-4C0E-8F5F-79F638DBDC5C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Plotly.NET.Verso", "src\Plotly.NET.Verso\Plotly.NET.Verso.fsproj", "{3C58E7CB-7FC6-4031-B807-A00B72F0E1DF}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Plotly.NET", "src\Plotly.NET\Plotly.NET.fsproj", "{BF17461A-8C55-4FCA-BB12-C7FA4AC39441}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common", "Common", "{E20385C6-4CD7-5BF4-C007-71E5EE00BC8D}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VersoBrowserTests", "tests\ExtensionLibsTests\VersoBrowserTests\VersoBrowserTests.fsproj", "{888575C4-AB72-4A6F-BD57-2DAE9797B81D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "VersoTests", "VersoTests", "{9AC19BB9-EF0B-987F-09AE-8434E842AE82}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{A1E1AFD6-AA71-8DED-553C-085284F01554}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VersoIntegrationTests", "tests\VersoTests\Integration\VersoIntegrationTests.fsproj", "{BD7E66B6-7E4F-499F-BD7D-F89FA57B61C9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -58,6 +80,26 @@ Global {B1214EE4-C1D4-4046-975D-A06373390E71}.Debug|Any CPU.Build.0 = Debug|Any CPU {B1214EE4-C1D4-4046-975D-A06373390E71}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1214EE4-C1D4-4046-975D-A06373390E71}.Release|Any CPU.Build.0 = Release|Any CPU + {EA82753E-7A5C-4C0E-8F5F-79F638DBDC5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA82753E-7A5C-4C0E-8F5F-79F638DBDC5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA82753E-7A5C-4C0E-8F5F-79F638DBDC5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA82753E-7A5C-4C0E-8F5F-79F638DBDC5C}.Release|Any CPU.Build.0 = Release|Any CPU + {3C58E7CB-7FC6-4031-B807-A00B72F0E1DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C58E7CB-7FC6-4031-B807-A00B72F0E1DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C58E7CB-7FC6-4031-B807-A00B72F0E1DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C58E7CB-7FC6-4031-B807-A00B72F0E1DF}.Release|Any CPU.Build.0 = Release|Any CPU + {BF17461A-8C55-4FCA-BB12-C7FA4AC39441}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF17461A-8C55-4FCA-BB12-C7FA4AC39441}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF17461A-8C55-4FCA-BB12-C7FA4AC39441}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF17461A-8C55-4FCA-BB12-C7FA4AC39441}.Release|Any CPU.Build.0 = Release|Any CPU + {888575C4-AB72-4A6F-BD57-2DAE9797B81D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {888575C4-AB72-4A6F-BD57-2DAE9797B81D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {888575C4-AB72-4A6F-BD57-2DAE9797B81D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {888575C4-AB72-4A6F-BD57-2DAE9797B81D}.Release|Any CPU.Build.0 = Release|Any CPU + {BD7E66B6-7E4F-499F-BD7D-F89FA57B61C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD7E66B6-7E4F-499F-BD7D-F89FA57B61C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD7E66B6-7E4F-499F-BD7D-F89FA57B61C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD7E66B6-7E4F-499F-BD7D-F89FA57B61C9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -69,6 +111,15 @@ Global {FEAB6AB7-1F9A-4E75-B047-F34C3AE1919B} = {6C49B0AB-8502-4E46-9348-64161F84E727} {24AE6CF2-8A86-4F8B-82D6-30D04D674933} = {CCB58D28-C02B-49A8-8614-8BD3D4FA9DF9} {B1214EE4-C1D4-4046-975D-A06373390E71} = {CCB58D28-C02B-49A8-8614-8BD3D4FA9DF9} + {B49D0B3A-AF90-AE88-9F71-7A7FA5B38005} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EA82753E-7A5C-4C0E-8F5F-79F638DBDC5C} = {B49D0B3A-AF90-AE88-9F71-7A7FA5B38005} + {3C58E7CB-7FC6-4031-B807-A00B72F0E1DF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {BF17461A-8C55-4FCA-BB12-C7FA4AC39441} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E20385C6-4CD7-5BF4-C007-71E5EE00BC8D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {888575C4-AB72-4A6F-BD57-2DAE9797B81D} = {B49D0B3A-AF90-AE88-9F71-7A7FA5B38005} + {9AC19BB9-EF0B-987F-09AE-8434E842AE82} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A1E1AFD6-AA71-8DED-553C-085284F01554} = {9AC19BB9-EF0B-987F-09AE-8434E842AE82} + {BD7E66B6-7E4F-499F-BD7D-F89FA57B61C9} = {A1E1AFD6-AA71-8DED-553C-085284F01554} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {67F9FFDF-3841-4DE1-9A55-9494FFC0A288} diff --git a/Plotly.NET.sln b/Plotly.NET.sln index 09303753..574edc5d 100644 --- a/Plotly.NET.sln +++ b/Plotly.NET.sln @@ -233,6 +233,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "data", "data", "{0D955C86-2 EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "InteractiveTests", "tests\ExtensionLibsTests\InteractiveTests\InteractiveTests.fsproj", "{04BC8CE5-B096-46AD-AB1D-674305BAF59A}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Plotly.NET.Verso", "src\Plotly.NET.Verso\Plotly.NET.Verso.fsproj", "{4E9DAEE7-0162-4E01-A690-CBA668F26B19}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExtensionLibsTests", "ExtensionLibsTests", "{B97B26B6-548F-4656-B980-20BD04D168C7}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VersoTests", "tests\ExtensionLibsTests\VersoTests\VersoTests.fsproj", "{30EDCE67-AAEC-4138-A24C-D752012F03F3}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VersoBrowserTests", "tests\ExtensionLibsTests\VersoBrowserTests\VersoBrowserTests.fsproj", "{BA1748AB-FF1A-441D-BE91-E81394AA7036}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "VersoTests", "VersoTests", "{DDBAA935-5A2C-D257-BB32-F100901CEBDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{610A9D23-4258-9593-5A5B-D958211BF92A}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "VersoIntegrationTests", "tests\VersoTests\Integration\VersoIntegrationTests.fsproj", "{F12070A1-8811-408F-B6A3-7B34023D3807}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -318,6 +332,30 @@ Global {04BC8CE5-B096-46AD-AB1D-674305BAF59A}.Dotnet|Any CPU.Build.0 = Debug|Any CPU {04BC8CE5-B096-46AD-AB1D-674305BAF59A}.Release|Any CPU.ActiveCfg = Release|Any CPU {04BC8CE5-B096-46AD-AB1D-674305BAF59A}.Release|Any CPU.Build.0 = Release|Any CPU + {4E9DAEE7-0162-4E01-A690-CBA668F26B19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E9DAEE7-0162-4E01-A690-CBA668F26B19}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E9DAEE7-0162-4E01-A690-CBA668F26B19}.Dotnet|Any CPU.ActiveCfg = Debug|Any CPU + {4E9DAEE7-0162-4E01-A690-CBA668F26B19}.Dotnet|Any CPU.Build.0 = Debug|Any CPU + {4E9DAEE7-0162-4E01-A690-CBA668F26B19}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E9DAEE7-0162-4E01-A690-CBA668F26B19}.Release|Any CPU.Build.0 = Release|Any CPU + {30EDCE67-AAEC-4138-A24C-D752012F03F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30EDCE67-AAEC-4138-A24C-D752012F03F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30EDCE67-AAEC-4138-A24C-D752012F03F3}.Dotnet|Any CPU.ActiveCfg = Debug|Any CPU + {30EDCE67-AAEC-4138-A24C-D752012F03F3}.Dotnet|Any CPU.Build.0 = Debug|Any CPU + {30EDCE67-AAEC-4138-A24C-D752012F03F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30EDCE67-AAEC-4138-A24C-D752012F03F3}.Release|Any CPU.Build.0 = Release|Any CPU + {BA1748AB-FF1A-441D-BE91-E81394AA7036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA1748AB-FF1A-441D-BE91-E81394AA7036}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA1748AB-FF1A-441D-BE91-E81394AA7036}.Dotnet|Any CPU.ActiveCfg = Debug|Any CPU + {BA1748AB-FF1A-441D-BE91-E81394AA7036}.Dotnet|Any CPU.Build.0 = Debug|Any CPU + {BA1748AB-FF1A-441D-BE91-E81394AA7036}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA1748AB-FF1A-441D-BE91-E81394AA7036}.Release|Any CPU.Build.0 = Release|Any CPU + {F12070A1-8811-408F-B6A3-7B34023D3807}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F12070A1-8811-408F-B6A3-7B34023D3807}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F12070A1-8811-408F-B6A3-7B34023D3807}.Dotnet|Any CPU.ActiveCfg = Debug|Any CPU + {F12070A1-8811-408F-B6A3-7B34023D3807}.Dotnet|Any CPU.Build.0 = Debug|Any CPU + {F12070A1-8811-408F-B6A3-7B34023D3807}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F12070A1-8811-408F-B6A3-7B34023D3807}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -359,6 +397,13 @@ Global {2DF92AB2-7B8C-48D5-B779-26BA1F31BCA1} = {A9025690-FABC-4BD3-AC94-F31B60A948B4} {0D955C86-2E86-4D50-828A-6CF5AD99A119} = {7B09CC0A-F1E1-4094-9DE4-B047581E01F0} {04BC8CE5-B096-46AD-AB1D-674305BAF59A} = {02886FBB-DB32-4BBB-A93C-E13EBF453ACC} + {4E9DAEE7-0162-4E01-A690-CBA668F26B19} = {0E87E47E-9EDC-4525-AF72-F0E139D54236} + {B97B26B6-548F-4656-B980-20BD04D168C7} = {EAE25A1F-86FC-426B-803F-1006D1AD06A8} + {30EDCE67-AAEC-4138-A24C-D752012F03F3} = {B97B26B6-548F-4656-B980-20BD04D168C7} + {BA1748AB-FF1A-441D-BE91-E81394AA7036} = {B97B26B6-548F-4656-B980-20BD04D168C7} + {DDBAA935-5A2C-D257-BB32-F100901CEBDA} = {EAE25A1F-86FC-426B-803F-1006D1AD06A8} + {610A9D23-4258-9593-5A5B-D958211BF92A} = {DDBAA935-5A2C-D257-BB32-F100901CEBDA} + {F12070A1-8811-408F-B6A3-7B34023D3807} = {610A9D23-4258-9593-5A5B-D958211BF92A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7177F1E1-341C-48AB-9864-6B525FFF7633} diff --git a/README.md b/README.md index 7c464f4f..fab7f5ce 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,20 @@ In short, Plotly.NET consists of multiple API layers and packages: - `Plotly.NET.Interactive` - This package provides interactive formatting extensions for .NET interactive notebooks. +- `Plotly.NET.Verso` - Inline chart formatting for Verso notebooks, including embedded offline charts. See the [Verso guide](src/Plotly.NET.Verso/README.md) for registration and migration from Polyglot. + - `Plotly.NET.ImageExport` - This package provides extensions for Plotly.NET to render charts as static images programmatically. - `Plotly.NET.CSharp` - This package provides an idiomatic C# API. Note that you can use the core API in C#, this package just removes some friction at some places. # Installation -You can get all Plotly.NET packages via nuget: +Published Plotly.NET packages are available via NuGet: | Package Name| Nuget | |---|---| | [Plotly.NET](src/Plotly.NET/README.md) | [![](https://img.shields.io/nuget/vpre/Plotly.NET)](https://www.nuget.org/packages/Plotly.NET/) | | [Plotly.NET.Interactive](src/Plotly.NET.Interactive/README.md) | [![](https://img.shields.io/nuget/vpre/Plotly.NET.Interactive)](https://www.nuget.org/packages/Plotly.NET.Interactive/) | +| [Plotly.NET.Verso](src/Plotly.NET.Verso/README.md) | Initial release; [local testing before publication](tests/README.md#verso-tests) | | [Plotly.NET.ImageExport](src/Plotly.NET.ImageExport/README.md) | [![](https://img.shields.io/nuget/vpre/Plotly.NET.ImageExport)](https://www.nuget.org/packages/Plotly.NET.ImageExport/) | | [Plotly.NET.CSharp](src/Plotly.NET.CSharp/README.md) | [![](https://img.shields.io/nuget/vpre/Plotly.NET.CSharp)](https://www.nuget.org/packages/Plotly.NET.CSharp/) | @@ -70,6 +73,8 @@ You can get all Plotly.NET packages via nuget: In F# projects, just add the `Plotly.NET` package to your project and you are ready to go! +For **Verso notebooks**, use [Plotly.NET.Verso](src/Plotly.NET.Verso/README.md) and its explicit extension-registration step. `Plotly.NET.Interactive` below is the adapter for .NET Interactive / Polyglot Notebooks. +
📖 Polyglot Notebook 📖 Quick start diff --git a/build/Build.fs b/build/Build.fs index 182bcbc3..7ad3b6ae 100644 --- a/build/Build.fs +++ b/build/Build.fs @@ -11,11 +11,15 @@ open Helpers open BasicTasks open TestTasks open PackageTasks +open VersoTasks open DocumentationTasks open ReleaseTasks initializeContext () +// Reference the independent integration module so its targets are registered. +let private _versoIntegration = runVersoIntegrationTests + open BasicTasks //workaround for tasks created with functions not being runnable. @@ -33,6 +37,7 @@ let sourceFiles = !! "src/Plotly.NET/**/*.fs" ++ "src/Plotly.NET.ImageExport/**/*.fs" ++ "src/Plotly.NET.Interactive/**/*.fs" + ++ "src/Plotly.NET.Verso/**/*.fs" ++ "build/*.fs" -- "**/obj/**/*.*" -- "**/bin/**/*.*" diff --git a/build/ProjectInfo.fs b/build/ProjectInfo.fs index 46e616cd..8587c29b 100644 --- a/build/ProjectInfo.fs +++ b/build/ProjectInfo.fs @@ -81,17 +81,26 @@ let testProjectsCore = [ let ImageExportTestProject = ProjectInfo.create("ImageExportTests", "tests/ExtensionLibsTests/ImageExportTests/ImageExportTests.fsproj") let CSharpTestProject = ProjectInfo.create("CSharpTests", "tests/ExtensionLibsTests/CSharpTests/CSharpTests.csproj") +let VersoTestProject = ProjectInfo.create("VersoTests", "tests/ExtensionLibsTests/VersoTests/VersoTests.fsproj") +let VersoBrowserTestProject = ProjectInfo.create("VersoBrowserTests", "tests/ExtensionLibsTests/VersoBrowserTests/VersoBrowserTests.fsproj") +let VersoIntegrationTestProject = ProjectInfo.create("VersoIntegrationTests", "tests/VersoTests/Integration/VersoIntegrationTests.fsproj") + +// Real-host tests have their own target because they install and launch the pinned Verso CLI. +let testProjectsVersoIntegration = [ VersoIntegrationTestProject ] /// let testProjectsExtensionsLibs = [ ImageExportTestProject CSharpTestProject + VersoTestProject + VersoBrowserTestProject ] let CoreProject = ProjectInfo.create("Plotly.NET", "src/Plotly.NET/Plotly.NET.fsproj", "src/Plotly.NET/RELEASE_NOTES.md") let InteractiveProject = ProjectInfo.create("Plotly.NET.Interactive", "src/Plotly.NET.Interactive/Plotly.NET.Interactive.fsproj", "src/Plotly.NET.Interactive/RELEASE_NOTES.md") let ImageExportProject = ProjectInfo.create("Plotly.NET.ImageExport", "src/Plotly.NET.ImageExport/Plotly.NET.ImageExport.fsproj", "src/Plotly.NET.ImageExport/RELEASE_NOTES.md") let CSharpProject = ProjectInfo.create("Plotly.NET.CSharp", "src/Plotly.NET.CSharp/Plotly.NET.CSharp.csproj", "src/Plotly.NET.CSharp/RELEASE_NOTES.md") +let VersoProject = ProjectInfo.create("Plotly.NET.Verso", "src/Plotly.NET.Verso/Plotly.NET.Verso.fsproj", "src/Plotly.NET.Verso/RELEASE_NOTES.md") /// contains project info about all projects let projects = [ @@ -99,6 +108,7 @@ let projects = [ InteractiveProject ImageExportProject CSharpProject + VersoProject ] /// docs are always targeting the version of the core project diff --git a/build/TestTasks.fs b/build/TestTasks.fs index 6a909816..282abf06 100644 --- a/build/TestTasks.fs +++ b/build/TestTasks.fs @@ -29,6 +29,9 @@ let buildTestsCore = createTestBuildTask "BuildTestsCore" [clean; build] (testBa let buildTestsExtensionsLibs = createTestBuildTask "BuildTestsExtensionsLibs" [clean; build] (testBaseProjects @ testProjectsExtensionsLibs) +let private testArguments (project: ProjectInfo) = + let results = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(project.ProjFile), "TestResults") + $"-tl --logger \"trx;LogFileName={project.Name}.trx\" --results-directory \"{results}\"" let createRunTestTask (name: string) (deps: BuildTask.TaskInfo list) (projects: ProjectInfo list) = BuildTask.create name deps { @@ -42,7 +45,7 @@ let createRunTestTask (name: string) (deps: BuildTask.TaskInfo list) (projects: NoBuild = true MSBuildParams = { testParams.MSBuildParams with DisableInternalBinLog = true } } - |> DotNet.Options.withCustomParams (Some "-tl") + |> DotNet.Options.withCustomParams (Some (testArguments testProjectInfo)) ) testProjectInfo.ProjFile ) @@ -61,7 +64,7 @@ let createRunTestFastTask (name: string) (projects: ProjectInfo list) = Configuration = DotNet.BuildConfiguration.fromString configuration MSBuildParams = { testParams.MSBuildParams with DisableInternalBinLog = true } } - |> DotNet.Options.withCustomParams (Some "-tl") + |> DotNet.Options.withCustomParams (Some (testArguments testProjectInfo)) ) testProjectInfo.ProjFile ) @@ -93,3 +96,28 @@ let runImageExportTestsFast = createRunSingleTestProjectFastTask ImageExportTest /// runs the CSharpTests project via incremental `dotnet test`, without cleaning first. let runCSharpTestsFast = createRunSingleTestProjectFastTask CSharpTestProject + +/// Focused Verso formatter/assembly-isolation tests without cleaning. +let runVersoTestsFast = createRunSingleTestProjectFastTask VersoTestProject + +/// Focused deterministic Chromium rendering tests without cleaning. +let runVersoBrowserTestsFast = createRunSingleTestProjectFastTask VersoBrowserTestProject + +let buildVersoTests = createTestBuildTask "BuildVersoTests" [clean; build] (testBaseProjects @ [VersoTestProject]) +let buildVersoBrowserTests = createTestBuildTask "BuildVersoBrowserTests" [clean; build] [VersoBrowserTestProject] +let runVersoTests = createRunTestTask "RunVersoTests" [buildVersoTests] [VersoTestProject] +let runVersoBrowserTests = createRunTestTask "RunVersoBrowserTests" [buildVersoBrowserTests] [VersoBrowserTestProject] + +/// Provision Chromium explicitly in CI; the same pinned browser is used by local tests. +let prepareVersoBrowser = + BuildTask.create "PrepareVersoBrowser" [] { + DotNet.build + (fun p -> + { p with + Configuration = DotNet.BuildConfiguration.fromString configuration + MSBuildParams = { p.MSBuildParams with DisableInternalBinLog = true } }) + VersoBrowserTestProject.ProjFile + let assembly = $"tests/ExtensionLibsTests/VersoBrowserTests/bin/{configuration}/net10.0/VersoBrowserTests.dll" + let result = DotNet.exec id assembly "--install-browser" + if result.ExitCode <> 0 then failwith "Chromium provisioning failed." + } diff --git a/build/VersoTasks.fs b/build/VersoTasks.fs new file mode 100644 index 00000000..30cbbd8b --- /dev/null +++ b/build/VersoTasks.fs @@ -0,0 +1,116 @@ +module VersoTasks + +open System +open System.IO +open System.Security.Cryptography +open System.Text.Json +open System.Text.Json.Nodes +open System.Xml.Linq +open BlackFox.Fake +open Fake.Core +open Fake.DotNet +open ProjectInfo + +let private artifacts = Path.GetFullPath("tests/VersoTests/artifacts") +let private feed = Path.Combine(artifacts, "feed") + +let private versionProperties (project: ProjectInfo) = + [ + "Version", project.PackageVersionTag + "PackageVersion", project.PackageVersionTag + "AssemblyVersion", project.AssemblyVersion + "InformationalVersion", project.AssemblyInformationalVersion + "TargetsForTfmSpecificContentInPackage", "" + ] + +/// A noninteractive package preparation path, independent of Pack/RunTestsAll. +/// Versions and configuration must agree between build, pack, and the host fixtures. +let packVersoIntegrationPackages = BuildTask.create "PackVersoIntegrationPackages" [] { + Directory.CreateDirectory(feed) |> ignore + let packageProjects = [ CoreProject; VersoProject ] + + for project in packageProjects do + DotNet.build + (fun options -> + { options with + Configuration = DotNet.BuildConfiguration.fromString configuration + MSBuildParams = + { options.MSBuildParams with + Properties = versionProperties project + DisableInternalBinLog = true } } + |> DotNet.Options.withCustomParams (Some "--no-dependencies")) + project.ProjFile + + DotNet.pack + (fun options -> + { options with + Configuration = DotNet.BuildConfiguration.fromString configuration + NoBuild = true + OutputPath = Some feed + MSBuildParams = + { options.MSBuildParams with + Properties = versionProperties project + DisableInternalBinLog = true } }) + project.ProjFile + + let assemblyPath (project: ProjectInfo) tfm = + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(project.ProjFile), "bin", configuration, tfm, project.Name + ".dll")) + let sha path = File.ReadAllBytes(path) |> SHA256.HashData |> Convert.ToHexString + let coreAssembly = assemblyPath CoreProject "netstandard2.0" + let versoAssembly = assemblyPath VersoProject "net8.0" + let manifest = + {| coreVersion = CoreProject.PackageVersionTag + versoVersion = VersoProject.PackageVersionTag + configuration = configuration + coreAssembly = coreAssembly + versoAssembly = versoAssembly + coreSha256 = sha coreAssembly + versoSha256 = sha versoAssembly |} + File.WriteAllText(Path.Combine(artifacts, "packages.json"), JsonSerializer.Serialize(manifest, JsonSerializerOptions(WriteIndented = true))) + + // The same directory can be opened manually; no machine-specific path is committed. + let config = + XDocument( + XElement(XName.Get "configuration", + XElement(XName.Get "packageSources", + XElement(XName.Get "clear"), + XElement(XName.Get "add", XAttribute(XName.Get "key", "local"), XAttribute(XName.Get "value", feed)), + XElement(XName.Get "add", XAttribute(XName.Get "key", "nuget.org"), XAttribute(XName.Get "value", "https://api.nuget.org/v3/index.json"))))) + config.Save(Path.Combine(artifacts, "NuGet.Config")) + let notebook = JsonNode.Parse(File.ReadAllText("tests/VersoTests/test.verso")) + let cells = notebook["cells"].AsArray() + cells[1]["source"] <- JsonValue.Create($"#!extension Plotly.NET.Verso {VersoProject.PackageVersionTag}\nopen Plotly.NET") + let sourceCell = JsonObject() + sourceCell["id"] <- JsonValue.Create(Guid.NewGuid().ToString()) + sourceCell["type"] <- JsonValue.Create("code") + // Register the feed explicitly with the shared extension resolver. + // This fixture choice is not a restriction on F# notebook setup. + sourceCell["language"] <- JsonValue.Create("csharp") + sourceCell["source"] <- JsonValue.Create("#i \"nuget: " + feed.Replace('\\', '/') + "\"") + sourceCell["outputs"] <- JsonArray() + cells.Insert(1, sourceCell) + File.WriteAllText(Path.Combine(artifacts, "test.verso"), notebook.ToJsonString(JsonSerializerOptions(WriteIndented = true))) + Trace.trace $"Verso integration packages and provenance: {artifacts}" +} + +let runVersoIntegrationTests = BuildTask.create "RunVersoIntegrationTests" [packVersoIntegrationPackages] { + DotNet.build + (fun options -> + { options with + Configuration = DotNet.BuildConfiguration.fromString configuration + MSBuildParams = { options.MSBuildParams with DisableInternalBinLog = true } }) + VersoIntegrationTestProject.ProjFile + + // ProcessStartInfo.ArgumentList avoids shell quoting across Windows and Unix. + let executable = Path.GetFullPath($"tests/VersoTests/Integration/bin/{configuration}/net10.0/VersoIntegrationTests.dll") + let start = Diagnostics.ProcessStartInfo("dotnet", UseShellExecute = false) + start.ArgumentList.Add(executable) + start.ArgumentList.Add(Path.GetFullPath(".")) + start.ArgumentList.Add(artifacts) + use integrationProcess = Diagnostics.Process.Start(start) + if not (integrationProcess.WaitForExit(20 * 60 * 1000)) then + integrationProcess.Kill(true) + failwith "Verso integration exceeded its 20 minute outer timeout." + if integrationProcess.ExitCode <> 0 then + failwith $"Verso integration failed with exit code {integrationProcess.ExitCode}. Inspect {artifacts}/runs." +} diff --git a/build/build.fsproj b/build/build.fsproj index ebd1ce10..a0fa7488 100644 --- a/build/build.fsproj +++ b/build/build.fsproj @@ -12,6 +12,7 @@ + diff --git a/docs/general/verso.fsx b/docs/general/verso.fsx new file mode 100644 index 00000000..7a5348a2 --- /dev/null +++ b/docs/general/verso.fsx @@ -0,0 +1,84 @@ +(** +--- +title: Verso notebooks +category: General +categoryindex: 1 +index: 20 +--- +*) + +(*** hide ***) + +(*** condition: prepare ***) +#r "nuget: Newtonsoft.JSON, 13.0.3" +#r "nuget: DynamicObj, 8.0.0" +#r "nuget: Giraffe.ViewEngine, 1.4.0" +#r "../../src/Plotly.NET/bin/Release/netstandard2.0/Plotly.NET.dll" + +(** +# Verso notebooks + +`Plotly.NET.Verso` formats chart values in Verso 1.2.3 and later compatible 1.x hosts. +Use `Plotly.NET.Interactive` for .NET Interactive / Polyglot Notebooks. +Chart-construction code is shared between both hosts; their registration steps differ. + +Before the initial Verso package release is published, follow +the [local package workflow](https://github.com/plotly/Plotly.NET/blob/dev/tests/README.md#verso-tests). + +## Registering the formatter + +Reference the selected package version in an F# notebook cell, then register the +extension in a separate cell. These are notebook commands, not commands to run in FSI. + +```text +[lang=text] +#r "nuget: Plotly.NET.Verso, 1.0.0" +``` + +```text +[lang=text] +#!extension Plotly.NET.Verso 1.0.0 +``` + +Verso can ask for approval when installing a package version. `#r` alone does not +activate the formatter; installation through the Extensions panel is also supported. + +## Creating a chart + +End an F# cell with the chart value: + +```fsharp +open Plotly.NET + +Chart.Point(x = [0; 1; 2], y = [1; 4; 9]) +|> Chart.withTitle "Squares" +``` + +The default asynchronously loads the Plotly CDN. Multiple cell outputs share a +pending load. For embedded offline chart output, replace the display options: + +```fsharp +Chart.Point(x = [0; 1; 2], y = [1; 4; 9]) +|> Chart.withDisplayOptions ( + DisplayOptions.init(PlotlyJSReference = PlotlyJSReference.Full) +) +``` + +`Full` embeds several megabytes of JavaScript in each output. It removes the chart's +Plotly download requirement; the notebook host and explicitly added external resources +may still need a connection. Explicit `CDN` URLs are supported. `NoReference` uses an +existing Plotly instance or pending load, and never starts a download. Replace any +old `Require` options with `CDN` or `Full`; RequireJS is unsupported in this adapter. + +## Troubleshooting and migration + +Text-only output usually means the formatter was not registered or enabled. Check the +host version and the extension registration step. Script download/rendering failures +appear inside the chart output; rerun to retry a failed download or select `Full`. +Do not override Monaco's global `define` function. + +When moving from Polyglot, change the package reference to `Plotly.NET.Verso` and add +the registration cell. The packages stay separate to avoid requiring both hosts' +dependency graphs. See the [package guide](https://github.com/plotly/Plotly.NET/blob/dev/src/Plotly.NET.Verso/README.md) +for HTML helper functions and the full reference-option contract. +*) diff --git a/plans/VersoExtension.md b/plans/VersoExtension.md new file mode 100644 index 00000000..c35f84af --- /dev/null +++ b/plans/VersoExtension.md @@ -0,0 +1,85 @@ +# Complete Plotly.NET.Verso and its testing pipeline + +Approved 2026-11-12. All four implementation chunks are complete and recorded as five local commits on `verso-ext`. Automated local validation passed on Windows and Linux. GitHub Actions execution and the complete manual VS Code checklist remain unverified; the user reported successful F# setup in VS Code. Packages remain unpublished. + +## Findings and decisions + +Verso fixed the Monaco/AMD issue in 1.0.15, but external Plotly loading must still be awaited. The prototype uses RequireJS and its tests mostly bypass the Verso helpers. Its net8.0 test project references a net10.0 shared test project. Current Verso also isolates extension assemblies, so notebook chart values need a bridge across assembly load contexts. + +- Keep Plotly.NET.Verso separate from Plotly.NET.Interactive. A combined package is technically possible but brings both host dependency graphs and requires both-host compatibility testing. Add prominent migration guidance to Interactive instead. +- Support/test Verso 1.2.3; pin Abstractions, Testing, and integration CLI to 1.2.3. Keep the shipped extension on net8.0 and run tests on net10.0. +- Pin Verso tests to Expecto 11.1.1, YoloDev.Expecto.TestSdk 0.16.1, Microsoft.NET.Test.Sdk 18.12.0; retain VSTest and disable optional MTP integration. Browser tests use PuppeteerSharp 25.15.0. +- Preserve GenericChartFormatter, toVersoHTML, toVersoHTMLOffline, extension ID and priority. Synchronize metadata with the built package version. +- Automate CLI/web-host coverage; perform VS Code installation/render/rerun/offline/save/reopen smoke separately. +- No publishing, automatic theme adaptation, or unrelated core/runtime upgrades. + +## Rendering contract + +`toVersoHTML` returns an adaptive notebook fragment. `toVersoHTMLOffline` returns a complete self-contained document. Preserve data/layout/config, descriptions and additional HTML tags without mutating charts. Serialize and retrieve embedded JavaScript through the originating core assembly. + +| Reference | Behavior | +|---|---| +| Missing | CDN matching the originating core runtime | +| CDN url | Load the supplied URL | +| Full | Embed Plotly for automatic offline rendering | +| NoReference | No download; use loaded Plotly or an existing pending load, otherwise actionable error | +| Require | Actionable migration message to use CDN or Full | + +## Planned self-contained commits + +- [x] **1. Dependencies and extension contracts.** Updated dependencies/frameworks and focused FAKE targets; implemented MIME/cancellation/metadata contracts and narrowly validated originating-assembly bridge. Tested actual helpers, unrelated values, cancellation, metadata and Chart/MultiChart in genuinely separate assembly contexts. +- [x] **2. Rendering and browser regressions.** Replaced RequireJS with asynchronous shared loading, readiness/error handling, retries, removed outputs and offline rendering. Added deterministic Chromium tests with dynamic cell insertion and locally fulfilled bundled Plotly; verified real rendered content, unique IDs, all reference modes and preserved chart options. Payload expectations use actual core serialization rather than handwritten snapshots. +- [x] **3. Reproducible packages and real host.** Added noninteractive FAKE packing, isolated feed/NuGet/temp/CLI directories and portable fixtures. Executed/saved through CLI, verified package hashes, then served/rendered/reran/reopened both modes. Bounded timeouts and child-process cleanup retain diagnostics. +- [x] **4. CI and documentation.** Registered projects/solutions/aggregate targets and Windows/Linux unit/browser jobs plus a separate Linux packaged-host job. Explicitly provision/cache Chromium and retain failure artifacts. Replaced the destructive repack script; added package README, guide, migration notes and release notes. Verified docs, packages and full clean tests. Actual platform/host execution is tracked below. + +## Acceptance and validation log + +- [x] Fresh local package registers and formats notebook-created charts in Verso 1.2.3. +- [x] Delayed CDN loading and fully embedded offline output render correctly. +- [x] Unit and browser tests exercise actual extension output and isolated assembly loading. +- [x] Windows and Linux unit/browser runs pass (Linux validated in an isolated Ubuntu 24.04 container). +- [x] Packaged CLI/web-host integration passes on Windows and Linux and proves local package provenance. +- [ ] Execute the configured GitHub Actions jobs on the final branch; local Docker validation is not a GitHub CI run. +- [ ] VS Code smoke: install, first render, multiple charts, rerun, offline, save/reopen. +- [x] Full clean `./build.cmd runTestsAll` and dedicated integration target pass before committing. +- [x] Package contents/dependencies and documentation generation verified. + +### Implementation notes and results + +- `RunVersoTestsFast`: 14/14 passed on Windows. Covers direct formatter contracts, independent Chart/MultiChart assembly contexts, date preservation, custom DisplayOptions subclasses and chart immutability. +- `RunVersoBrowserTestsFast`: 17/17 passed on Windows, PuppeteerSharp 25.15.0 / Chromium 155.0.8059.84. Initial failures exposed only a harness mistake treating Plotly's data-URL image probe as external networking; corrected while retaining strict external request assertions. +- `RunVersoIntegrationTests`: passed in 61.6 seconds. Online fixture 4/4 cells and offline fixture 3/3 cells passed, with local core/extension SHA-256 provenance. Both actual web-host flows verified initial rendering, UI-kernel provenance, two reruns, saving the latest chart ID, cold reopening and matching data, with no browser/console errors. Artifacts: `tests/VersoTests/artifacts/runs/20261112T125545-27c53a76` (including chart screenshots). +- `Chart.withDisplayOptions` merges in this checkout. Independent test fixtures use `GenericChart.setDisplayOptions` to select truly absent or explicit references; the extension itself never mutates chart options. +- Generated manual fixtures use C# `#i` to register the feed explicitly with the shared extension resolver. Correction after the user's VS Code check: F# `#i` is supported and worked in their setup. The earlier blanket claim that F# cannot be used was not established by our tests: automated host fixtures obtain their feed through `NuGet.Config`, without either language's `#i`. Separate resolver implementations do not by themselves prove failure in a particular host/configuration. Updated the test guide and build comments accordingly; the reason the user's setup resolves successfully has not been independently traced. +- VS Code has Verso 1.2.3 installed. Agent computer-use approval timed out, but the user subsequently reported successful F# setup and that the integration looked good. The complete install/render/rerun/offline/save/reopen checklist has not been explicitly recorded. +- Host-harness details learned from real execution: open notebooks through the toolbar after the interactive UI is ready (the 1.2.3 recovery URL can wait for consent before the UI mounts); await completed readable JSON when saving large Full outputs; allow the host's exact Monaco 0.45.0/Mermaid 11 asset paths while blocking chart networking in Full mode. Use independent server processes per fixture. +- Full clean `./build.cmd runTestsAll`: passed in 4m07s, **972 core + 6 ImageExport + 139 C# + 14 Verso + 17 Verso browser = 1,148 passed**, with the same 2 existing pending ImageExport tests skipped. TRX results are retained in each test project's `TestResults` directory. +- Generated the shared line fixture's actual data/layout/config through `tools/chart-baseline-generation/generate-chart-markup.fsx`; it matched the browser/host semantics (`scatter`, `lines`, x `[1,2,3]`, y `[4,6,5]`). Temporarily pointed the harness at current Release test dependencies because a clean build removes console Debug output, then restored the harness byte-for-byte. No handwritten large snapshot was introduced. +- Workflow YAML parses and both solution files list the new projects. Removed incidental x86/x64 platform additions from `dotnet sln add`; existing solution platforms are preserved. +- Documentation: full clean `BuildDocs` passed in 5m37s; a focused strict/evaluated check of the Verso guide also passed. The generated site includes the guide and both public Verso API pages. Refreshed an old local fsdocs project cache and corrected literate-script syntax so registration examples are displayed without trying to restore the unpublished package. +- Final `PrepareVersoBrowser` and `PackVersoIntegrationPackages` passed on Windows. Fresh package assembly hashes match both the build manifest and the successful Windows integration run byte-for-byte; the adapter contains the current README, core dependency `[6.0.0, 7.0.0)`, Verso.Abstractions minimum 1.2.3, and no test/browser dependencies. +- Additional isolated Ubuntu 24.04/.NET 10 Docker validation found FAKE's old MSBuild binary-log parser failing after the new provisioning target's build. Set `DisableInternalBinLog = true`, consistent with the other build calls, and verified the target on both Windows and Linux. +- Linux validation passed with Ubuntu 24.04.4, SDK 10.0.302/runtime 10.0.10 (only .NET 10 installed), and Chromium 155.0.8059.84: **14/14 unit tests, 17/17 browser tests**, explicit browser provisioning, and packaged CLI/web-host integration (78.8 seconds including packing). Both reference modes passed CLI output/provenance and actual web rendering/reruns/save/reopen. The temporary container was removed; tested source hashes matched the workspace. Log: `tests/VersoTests/artifacts/linux-validation/linux-validation.log`; 23 retained result files under `linux-validation/results`, host run `20261112T132534-53c85c64`. +- Before committing, repeated full clean `runTestsAll`: **1,148 passed, 2 existing skips**, in 4m08.6s (`tests/VersoTests/artifacts/precommit-full-tests.log`). +- The additional pre-commit host run exposed a harness race: cell-local completion could precede notebook-wide readiness, so clicking the next disabled Run button did nothing. The harness now waits up to 90 seconds for an enabled button and issues exactly one click; completion also waits for Run to be re-enabled. Existing execution/output assertions remain intact. The corrected Windows host run passed both CLI and browser modes in 96.2s including packing (`precommit-integration-retry.log`, run `20261112T140142-f28c570c`). The earlier successful Linux run preceded this harness-only readiness guard. +- GitHub Actions has not yet run for this feature branch. The complete manual VS Code checklist remains unrecorded as described above. + +## Commit record + +The formatter contracts and renderer are committed together because the new bridge, HTML helpers, and script resource depend on one another. Browser aggregate activation is committed with CI provisioning so earlier commits do not require unprovisioned browser libraries. + +| Commit | Scope | +|---|---| +| `443ffc28` | Formatter contracts, isolated assembly bridge, asynchronous/offline rendering, dependency updates, and 14 unit tests | +| `3616296b` | 17 browser regressions, focused FAKE targets, Chromium provisioning, solution registration, and test diagnostics | +| `6a64b36e` | Noninteractive packing, isolated CLI/web-host tests, execution-readiness guard, and portable manual fixtures | +| `86eff4b7` | Windows/Linux Chromium provisioning, aggregate browser tests, separate Linux host job, and CI artifacts | +| Documentation commit containing this plan | Installation/migration guides, packaged README, release notes, and final validation record | + +## Sources + +- [Original upstream discussion](https://github.com/DataficationSDK/Verso/discussions/30) +- [Verso 1.2.3](https://github.com/DataficationSDK/Verso/releases/tag/verso-v1.2.3) +- [Extension packaging](https://github.com/DataficationSDK/Verso/blob/verso-v1.2.3/docs/extensions/packaging-and-publishing.md) +- [Isolated formatter precedent](https://github.com/DataficationSDK/Verso/blob/verso-v1.2.3/src/Verso.DataFrame/Formatters/DataFrameFormatter.cs) +- [CLI commands](https://github.com/DataficationSDK/Verso/blob/verso-v1.2.3/src/Verso.Cli/README.md) diff --git a/plans/VersoPresentation.md b/plans/VersoPresentation.md new file mode 100644 index 00000000..1bfabb81 --- /dev/null +++ b/plans/VersoPresentation.md @@ -0,0 +1,114 @@ +# Verso notebook presentation and output lifecycle + +Status: deferred until after the v1 release, per the user's decision on 2026-11-12. The current implementation in `VersoExtension.md` defines the v1 scope; this proposal is not a release requirement and implementation has not started. +Baseline: local commit `6ef04eb8`; reviewed maintainer fork `bd568aba75134a08ba02a8320251e86258384592`. + +## Objective + +Add the maintainer's useful notebook presentation behaviors to our tested Verso adapter: host-aware colors/fonts, container-width sizing, and convenient image-download defaults. Add explicit disposal of rendered outputs before introducing more browser observers and event handlers. + +Retain the inline `text/html` renderer, shared asynchronous Plotly loader, originating-assembly bridge, and existing public HTML helpers. Widget/iframe output is a separate architectural decision, not a prerequisite for these features. + +## Proposed public behavior + +Add a small per-chart API in `Plotly.NET.Verso`, provisionally `PresentationOptions` and `Presentation.withOptions options chart`: + +| Option | Default | Opt-in behavior | +|---|---|---| +| Theme | `Preserve` | `FollowHost`: apply host presentation colors/fonts under the precedence rules below and follow live theme changes | +| Sizing | `Preserve` | `FitContainerWidth`: deliberately replace rendered width with the output container's width; keep chart height | +| Snapshot | `Preserve` | `NotebookDefaults`: fill missing image-download filename/format/scale settings | + +The names are proposed API names, not existing functions. A chart with no presentation metadata must render as it does today. Lifecycle cleanup applies to all charts independently of these choices. + +Store options as a versioned JSON string under a reserved DisplayOptions property, such as `Plotly.NET.Verso.Presentation`. The helper copies the DisplayOptions container and attaches the new metadata without changing the source chart or its nested HTML nodes. Read the metadata through the originating assembly's public DynamicObj API; pass strings and simple values through the bridge, not foreign option types. Do not introduce mutable global settings that can diverge between kernel and extension assembly contexts. + +Read policy once per formatted output. Later changes to another chart must not change existing output policies. Live host-theme changes are the deliberate exception for outputs using `FollowHost`. Recommend applying the helper after `Chart.combine`; do not invent trace-level option merging in this change. + +## Goal 1: deterministic output disposal + +- Track each rendered output by its actual DOM element and unique ID. +- When a rendered output is removed or replaced, call `Plotly.purge` and remove adapter-owned listeners, observers, timers, and queued animation-frame work. +- Removal during loading must prevent subsequent `newPlot`, error insertion, or ready-state updates for that output. Keep a shared load alive when another output still needs it. +- Removal during asynchronous drawing must prevent later callbacks from reviving the output. Perform final cleanup after drawing settles if it can create resources after the initial disposal. +- Keep lifecycle tracking bounded: unregister disposed elements and stop shared observation when no outputs need it. Do not attach one permanent document-wide observer per cell. +- Keep shared Plotly availability and failed-load retry behavior intact. Do not remove another cell's library or output. + +Acceptance tests use real rendering plus spies on purge and adapter-owned registrations. Ready-output removal purges once; in-flight drawing may require final cleanup again. Repeated reruns leave one live chart and its expected active subscriptions. Test one of two waiting cells being removed, removal while drawing, and removal before a queued callback fires. Assert observable cleanup rather than unreliable heap-size thresholds. + +## Goal 2: optional host-theme adaptation + +- Apply theme values before the first `Plotly.newPlot` call so the first rendered frame uses the selected palette. +- Use the existing Verso theme context as fallback and the host's resolved CSS tokens when present. Listen for `verso:themechanged` only for opted-in, eligible outputs. +- Scope every update to that chart's element. Do not wrap global `Plotly.newPlot`/`react` or restyle all charts on the page. +- First-version coverage is paper/plot backgrounds, font family/size/color, and Cartesian axis grid/line/zero-line/tick colors, including Cartesian subplots. Trace colors, colorways, colorscales, and family-specific 3D/polar/geo/ternary styling are outside this change. +- Missing or invalid theme tokens leave the corresponding original presentation value in place. Do not fetch fonts or inject external font resources. +- Theme changes must preserve data, chart IDs, zoom/ranges, and explicit user settings; they must not recreate the chart with `newPlot`. + +Precedence is explicit: + +1. Direct layout and trace properties win, including properties whose values happen to equal library defaults. +2. A non-stock custom template remains authoritative; skip automatic theme adaptation for that chart in this first version. +3. An absent template or a structurally stock Plotly.NET template is eligible only when `FollowHost` was selected. Apply a render-only presentation overlay while retaining the other template settings. +4. Obtain the stock template baseline from the chart's originating assembly (`ChartTemplates.plotly`), not the extension's assembly or the mutable current `Defaults.DefaultTemplate`. Do not retain foreign assembly/type objects in static caches. + +There is no historical provenance for an explicitly supplied template identical to the stock template. Under this contract, opting into `FollowHost` makes that template eligible too; `Preserve` keeps it unchanged. Do not claim that value comparison can reconstruct how a chart was created. + +Acceptance tests cover light/dark first draw, live theme switching, fallback with absent CSS tokens, explicit layout overrides, custom templates, stock-equivalent templates, Cartesian subplots, independent opted-in/opted-out outputs, and unchanged zoom after a theme change. Verify source-chart immutability and settings round-trip for both normal and foreign-assembly charts. No updates may run after disposal. + +## Goal 3: optional container-width sizing + +- `Preserve` retains current dimensions and behavior, including an explicitly supplied width equal to the library default. +- `FitContainerWidth` explicitly authorizes overriding width for this rendered output, including a previously supplied width. Keep its height, data, and other layout settings unchanged. +- Observe a stable output wrapper's available content width, not the document root or the graph's own width. Respond when a sidebar or notebook column changes without a window resize. +- Ignore zero-width measurements until the output becomes visible. Coalesce rapid changes and do nothing when the effective width has not changed. +- Resize the existing graph; do not create a new chart or introduce height/width feedback loops. Do not globally force `config.responsive=false` as the iframe-specific fork implementation does. +- Preserve zoom/ranges during resize and disconnect the observer and queued work on disposal. + +Acceptance tests change parent width without dispatching `window.resize`, check actual Plotly dimensions, preserve height, test hidden-to-visible output, rapid resizing, two independently sized cells, preserved zoom, and removed outputs. Explicit-width preservation is tested in `Preserve`; deliberate width replacement is tested in `FitContainerWidth`. + +## Goal 4: optional image-download defaults + +- In `NotebookDefaults`, fill only missing `toImageButtonOptions` properties: PNG format, scale 2, and a filename derived from the chart title. +- Preserve every supplied export property, including filename, format, scale, width, and height. Preserve all other config values and custom modebar settings. +- Produce a bounded, filesystem-safe title slug, removing title markup, separators, control characters, and invalid trailing characters. Handle reserved filenames and empty/unusable titles with a stable `plotly-chart` fallback; limit generated filenames to 80 characters before the extension. +- Resolve values per output without modifying the original chart. Do not alter trace data, titles, or normal chart display dimensions to improve exports. + +Acceptance tests cover plain/markup/Unicode/empty/long/reserved titles and independently supplied option overrides. Verify the modebar receives the expected filename/options and generate an actual PNG, checking its dimensions at scale 2. A self-contained line-chart fixture must render and export with external HTTP requests blocked. + +## Goal 5: preserve compatibility and prove host behavior + +- Keep `GenericChartFormatter`, `Formatters.toVersoHTML`, and `Formatters.toVersoHTMLOffline` signatures and output contracts. +- Preserve custom CDN, missing-reference CDN, `Full`, `NoReference`, and actionable `Require` behavior, as well as MIME filtering, cancellation, metadata, descriptions, and additional HTML tags. +- Keep render-only changes separate from source chart JSON. Do not reintroduce date-string conversion while processing captured JSON. +- Extend packaged CLI/web-host fixtures to exercise the new policies, reruns, theme updates, resizing, save, and reopen. Reopened opted-in output follows the current host theme and container width; opted-out output keeps its chart styling. +- Existing helpers remain usable outside Verso. If no host theme context or CSS tokens are available, keep the original chart palette; do not manufacture a new default theme or fetch resources. +- Run deterministic unit/browser tests on Windows and Linux, packaged integration, documentation generation, and the required full clean suite before committing. Record actual environments and outcomes. +- Perform and record a manual VS Code check of dark/light switching, notebook-width changes, configured versus preserved export options, repeated reruns, offline output, and save/reopen. Do not substitute automated web-host tests for this check. + +## Planned commits + +- [ ] **1. Per-chart presentation policy.** Add typed options, immutable metadata helper, bridge capture, and unit/foreign-assembly round-trip tests. Document default/override semantics in API comments. +- [ ] **2. Output disposal.** Add lifecycle tracking and purge/observer cleanup with browser regressions for removal, reruns, and pending work. +- [ ] **3. Host themes.** Add initial and live theme application, explicit precedence rules, and unit/browser tests, including multi-cell and subplot cases. +- [ ] **4. Container sizing.** Add opt-in width observation, hidden-output handling, and browser resize/disposal/zoom tests. +- [ ] **5. Snapshot defaults.** Add safe filename generation and missing-property defaults with unit tests and actual browser export checks. +- [ ] **6. Packaged host coverage and documentation.** Extend host fixtures and manuals, attribute adapted maintainer code, run final validation, and record results. + +## Explicit exclusions + +- Switching the default formatter to `text/x-verso-widget` or changing existing helper return formats. +- Global monkey-patching of Plotly or page-wide selectors that affect unrelated charts. +- Global mutable theme/offline flags, silently overriding explicit config, or treating default-value equality as author-intent provenance. +- Full styling support for every Plotly trace family, new core chart APIs, unrelated dependency/runtime upgrades, or publishing packages. +- Claiming a demonstrated memory leak: removal cleanup is a source-supported lifecycle concern that the new tests must establish precisely. + +## Sources and attribution + +Adapt selected concepts/code from Torrey Betts' fork with attribution in the implementation commits. Its iframe-specific scripts require adaptation for the existing inline renderer. + +- [Fork theme/sizing/export implementation](https://github.com/TorreyBetts/Plotly.NET/blob/bd568aba75134a08ba02a8320251e86258384592/src/Plotly.NET.Verso/Theming.fs) +- [Fork formatter and widget contract](https://github.com/TorreyBetts/Plotly.NET/blob/bd568aba75134a08ba02a8320251e86258384592/src/Plotly.NET.Verso/Formatters.fs) +- [Verso 1.2.3 output handling](https://github.com/DataficationSDK/Verso/blob/6f2e3dd9103be27931340bcb0bba2e326590f992/src/Verso.Blazor.Shared/Components/Notebook/OutputView.razor) +- [Verso theme context](https://github.com/DataficationSDK/Verso/blob/6f2e3dd9103be27931340bcb0bba2e326590f992/src/Verso.Abstractions/Contexts/IThemeContext.cs) +- [Inline theme-change event](https://github.com/DataficationSDK/Verso/blob/6f2e3dd9103be27931340bcb0bba2e326590f992/src/Verso.Blazor.Shared/wwwroot/js/verso-inline-layout-bridge.js) diff --git a/src/Plotly.NET.Interactive/README.md b/src/Plotly.NET.Interactive/README.md index b696f4c9..def0625a 100644 --- a/src/Plotly.NET.Interactive/README.md +++ b/src/Plotly.NET.Interactive/README.md @@ -2,6 +2,8 @@ Display [Plotly.NET](https://plotly.net/) charts directly in .NET Interactive notebooks, including Polyglot Notebooks. This package registers an HTML formatter for chart values, so charts appear inline with zooming, panning, and hover information. +**Moving to Verso?** Use [Plotly.NET.Verso](https://github.com/plotly/Plotly.NET/blob/dev/src/Plotly.NET.Verso/README.md), the separate adapter for Verso 1.2.3+. Keep your chart code, replace the notebook package reference, and register the Verso formatter with `#!extension Plotly.NET.Verso `. The Verso guide covers offline charts and local packages for its initial release. This package continues to serve .NET Interactive / Polyglot Notebooks. + ## Installation and quick start Run this in an F# notebook cell: diff --git a/src/Plotly.NET.Verso/ChartBridge.fs b/src/Plotly.NET.Verso/ChartBridge.fs new file mode 100644 index 00000000..298943a4 --- /dev/null +++ b/src/Plotly.NET.Verso/ChartBridge.fs @@ -0,0 +1,110 @@ +namespace Plotly.NET.Verso + +open System +open System.IO +open System.Reflection + +/// Only strings cross the boundary between the kernel's and extension's assembly load contexts. +module internal ChartBridge = + type Reference = Cdn of string | Full | NoReference | Require + + type Snapshot = { + Json: string + Reference: Reference + PlotlySource: string option + HeadTags: string + Description: string + Title: string + Charset: string + DocumentDescription: string + Favicon: string + } + + let private flags = BindingFlags.Public ||| BindingFlags.Static + + let rec private chartType (candidate: Type) = + if isNull candidate then None + elif candidate.FullName = "Plotly.NET.GenericChart" + && candidate.Assembly.GetName().Name = "Plotly.NET" then Some candidate + else chartType candidate.BaseType + + let private methodWithArgument (owner: Type) name (argumentType: Type) = + owner.GetMethod(name, flags, null, [| argumentType |], null) + + let tryGetChartType (value: obj) = + if isNull value then None + else + chartType (value.GetType()) + |> Option.filter (fun sourceType -> + let serialize = methodWithArgument sourceType "toJson" sourceType + let display = methodWithArgument sourceType "getDisplayOptions" sourceType + not (isNull serialize) && serialize.ReturnType = typeof && not (isNull display)) + + let private invoke (owner: Type) name (argumentType: Type) argument = + let methodInfo = methodWithArgument owner name argumentType + if isNull methodInfo then invalidOp $"The originating Plotly.NET assembly does not provide {owner.Name}.{name}." + methodInfo.Invoke(null, [| argument |]) + + let private property name (value: obj) = + let info = value.GetType().GetProperty(name) + if isNull info then invalidOp $"The originating Plotly.NET value does not provide {name}." + info.GetValue(value) + + let private renderNodes (methodInfo: MethodInfo) options = + let nodes = methodInfo.Invoke(null, [| options |]) + let nodeType = methodInfo.ReturnType.GetGenericArguments()[0] + let renderer = nodeType.Assembly.GetType("Giraffe.ViewEngine.RenderView+AsString", true) + invoke renderer "htmlNodes" methodInfo.ReturnType nodes :?> string + + let capture forceOffline (value: obj) = + let sourceType = tryGetChartType value |> Option.defaultWith (fun () -> invalidArg "value" "Expected a Plotly.NET GenericChart.") + let options = invoke sourceType "getDisplayOptions" sourceType value + // Static getters belong to the declared base type, even for custom options subclasses. + let optionsType = (methodWithArgument sourceType "getDisplayOptions" sourceType).ReturnType + let get name = invoke optionsType name optionsType options + let getString name = get name :?> string + let htmlList name = + let getter = methodWithArgument optionsType name optionsType + if isNull getter then invalidOp $"The originating Plotly.NET assembly does not provide {name}." + renderNodes getter options + let resourceName = + sourceType.Assembly.GetManifestResourceNames() + |> Array.filter (fun name -> name.StartsWith("Plotly.NET.plotly-", StringComparison.Ordinal) && name.EndsWith(".min.js", StringComparison.Ordinal)) + |> function + | [| name |] -> name + | _ -> invalidOp "Cannot identify the originating Plotly.NET JavaScript resource." + let version = resourceName.Substring("Plotly.NET.plotly-".Length).Replace(".min.js", "") + let reference = + if forceOffline then Full + else + match get "tryGetPlotlyReference" with + | null -> Cdn $"https://cdn.plot.ly/plotly-{version}.min.js" + | some -> + let reference = property "Value" some + let isCase name = property name reference :?> bool + if isCase "IsFull" then Full + elif isCase "IsCDN" then Cdn (property "Item" reference :?> string) + elif isCase "IsNoReference" then NoReference + elif isCase "IsRequire" then Require + else invalidOp "Unsupported originating Plotly.NET JavaScript reference." + let source = + match reference with + | Full -> + use stream = sourceType.Assembly.GetManifestResourceStream(resourceName) + use reader = new StreamReader(stream) + Some (reader.ReadToEnd()) + | _ -> None + let favicon = get "getDocumentFavicon" + let faviconType = (methodWithArgument optionsType "getDocumentFavicon" optionsType).ReturnType + let renderer = faviconType.Assembly.GetType("Giraffe.ViewEngine.RenderView+AsString", true) + { + Json = invoke sourceType "toJson" sourceType value :?> string + Reference = reference + PlotlySource = source + HeadTags = htmlList "getAdditionalHeadTags" + Description = htmlList "getChartDescription" + Title = getString "getDocumentTitle" + Charset = getString "getDocumentCharset" + DocumentDescription = getString "getDocumentDescription" + Favicon = invoke renderer "htmlNode" faviconType favicon :?> string + } diff --git a/src/Plotly.NET.Verso/Extension.fs b/src/Plotly.NET.Verso/Extension.fs new file mode 100644 index 00000000..fac1b261 --- /dev/null +++ b/src/Plotly.NET.Verso/Extension.fs @@ -0,0 +1,50 @@ +namespace Plotly.NET.Verso + +open System +open System.Collections.Generic +open System.Reflection +open System.Threading.Tasks +open Verso.Abstractions + +[] +type GenericChartFormatter() = + + interface IExtension with + member _.ExtensionId = "net.plotly.plotly-net-verso" + member _.Name = "Plotly.NET" + member _.Version = + let assembly = typeof.Assembly + match assembly.GetCustomAttribute() with + | null -> assembly.GetName().Version.ToString(3) + | version -> version.InformationalVersion.Split('+')[0] + member _.Author = "Plotly.NET Contributors" + member _.Description = "Renders Plotly.NET GenericChart objects as interactive charts in Verso notebooks." + member _.OnLoadedAsync(_ctx) = Task.CompletedTask + member _.OnUnloadedAsync() = Task.CompletedTask + + interface IDataFormatter with + // A local GenericChart Type fails Verso's prefilter for values from the kernel's ALC. + member _.SupportedTypes: IReadOnlyList = [| typeof |] + + member _.Priority = 100 + + member _.CanFormat(value, ctx) = + String.Equals(ctx.MimeType, "text/html", StringComparison.OrdinalIgnoreCase) + && (ChartBridge.tryGetChartType value |> Option.isSome) + + member _.FormatAsync(value, ctx) = + task { + ctx.CancellationToken.ThrowIfCancellationRequested() + try + if not (String.Equals(ctx.MimeType, "text/html", StringComparison.OrdinalIgnoreCase)) then + invalidArg "ctx" "The Plotly.NET formatter supports text/html output." + let snapshot = ChartBridge.capture false value + ctx.CancellationToken.ThrowIfCancellationRequested() + let html = Rendering.render false snapshot + ctx.CancellationToken.ThrowIfCancellationRequested() + return CellOutput(MimeType = "text/html", Content = html) + with + | :? OperationCanceledException -> return raise (OperationCanceledException(ctx.CancellationToken)) + | error -> + return CellOutput(MimeType = "text/html", Content = Rendering.error (error.GetBaseException().Message), IsError = true) + } diff --git a/src/Plotly.NET.Verso/Formatters.fs b/src/Plotly.NET.Verso/Formatters.fs new file mode 100644 index 00000000..8b7f196e --- /dev/null +++ b/src/Plotly.NET.Verso/Formatters.fs @@ -0,0 +1,64 @@ +namespace Plotly.NET.Verso + +open System +open System.IO +open System.Net +open Newtonsoft.Json +open Newtonsoft.Json.Linq +open Plotly.NET + +module internal Rendering = + let private script = lazy ( + use stream = typeof.Assembly.GetManifestResourceStream("Plotly.NET.Verso.Renderer.js") + use reader = new StreamReader(stream) + reader.ReadToEnd()) + + let private encode (text: string) = WebUtility.HtmlEncode(text) + + let error message = + $"
Plotly.NET: {encode message}
" + + let render document (snapshot: ChartBridge.Snapshot) = + let id = "plotly-" + Guid.NewGuid().ToString("N") + let mode, url = + match snapshot.Reference with + | ChartBridge.Cdn url -> "cdn", url + | ChartBridge.Full -> "full", "" + | ChartBridge.NoReference -> "none", "" + | ChartBridge.Require -> "require", "" + let payload = JObject() + payload["id"] <- JValue(id) + payload["mode"] <- JValue(mode) + payload["url"] <- JValue(url) + // Keep the originating serializer's JSON intact (including date strings). + // Escape HTML delimiters before putting its raw JSON in an inline script. + let chartJson = snapshot.Json.Replace("<", "\\u003c").Replace(">", "\\u003e").Replace("&", "\\u0026") + payload["chart"] <- JRaw(chartJson) + let settings = JsonSerializerSettings(StringEscapeHandling = StringEscapeHandling.EscapeHtml) + let json = JsonConvert.SerializeObject(payload, Formatting.None, settings) + let library = + snapshot.PlotlySource + |> Option.map (fun source -> "") + |> Option.defaultValue "" + let chart = + $"
" + + library + + "" + + snapshot.Description + if document then + "" + encode snapshot.Title + "" + snapshot.Favicon + snapshot.HeadTags + + "" + chart + "" + else snapshot.HeadTags + chart + +module Formatters = + /// Creates Verso cell HTML, loading Plotly asynchronously from the chart's CDN reference. + /// A Full reference embeds Plotly for offline use; NoReference uses an already loaded library. + let toVersoHTML (gChart: GenericChart) = + ChartBridge.capture false gChart |> Rendering.render false + + /// Creates a complete HTML document with the originating chart's Plotly library embedded. + /// User-supplied head tags and chart resources are preserved and may require network access. + let toVersoHTMLOffline (gChart: GenericChart) = + ChartBridge.capture true gChart |> Rendering.render true diff --git a/src/Plotly.NET.Verso/Plotly.NET.Verso.fsproj b/src/Plotly.NET.Verso/Plotly.NET.Verso.fsproj new file mode 100644 index 00000000..3b9a2b03 --- /dev/null +++ b/src/Plotly.NET.Verso/Plotly.NET.Verso.fsproj @@ -0,0 +1,68 @@ + + + + net8.0 + Library + true + true + true + true + snupkg + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + Gregor Beyerle, Timo Mühlhaus, Kevin Schneider, F# open source contributors + A Verso Notebook Extension for displaying Plotly.NET charts. + A Verso Notebook Extension for displaying Plotly.NET charts. + MIT + https://plotly.net + logo.png + README.md + visualization charting plotly fsharp csharp verso notebook + https://github.com/plotly/Plotly.NET/ + git + https://github.com/plotly/Plotly.NET/blob/dev/LICENSE + https://github.com/plotly/Plotly.NET/blob/dev/RELEASE_NOTES.md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ProjectReferenceWithExplicitPackageVersion Include="@(ProjectReference->'%(FullPath)')" Condition="'%(ProjectReference.PackageVersion)' != ''" /> + <_ProjectReferenceWithExactPackageVersion Include="@(ProjectReference->'%(FullPath)')" Condition="'%(ProjectReference.ExactVersion)' == 'true'" /> + <_ProjectReferenceWithReassignedVersion Include="@(_ProjectReferencesWithVersions)" Condition="'%(Identity)' != '' And '@(_ProjectReferenceWithExplicitPackageVersion)' == '@(_ProjectReferencesWithVersions)'"> + @(_ProjectReferenceWithExplicitPackageVersion->'%(PackageVersion)') + + <_ProjectReferenceWithReassignedVersion Include="@(_ProjectReferencesWithVersions)" Condition="'%(Identity)' != '' And '@(_ProjectReferenceWithExactPackageVersion)' == '@(_ProjectReferencesWithVersions)'"> + [@(_ProjectReferencesWithVersions->'%(ProjectVersion)')] + + <_ProjectReferencesWithVersions Remove="@(_ProjectReferenceWithReassignedVersion)" /> + <_ProjectReferencesWithVersions Include="@(_ProjectReferenceWithReassignedVersion)" /> + + + + diff --git a/src/Plotly.NET.Verso/README.md b/src/Plotly.NET.Verso/README.md new file mode 100644 index 00000000..c413b765 --- /dev/null +++ b/src/Plotly.NET.Verso/README.md @@ -0,0 +1,80 @@ +# Plotly.NET.Verso + +Display Plotly.NET charts inline in [Verso notebooks](https://github.com/DataficationSDK/Verso), with interactive zooming, panning and hover information. + +## Compatibility + +Plotly.NET.Verso 1.0.0 requires **Verso 1.2.3 or later in the compatible 1.x host line** and Plotly.NET `>= 6.0.0` and `< 7.0.0`. The extension targets `net8.0` and also runs on newer compatible .NET runtimes. It uses Verso's supported `IDataFormatter` extension contract and isolated assembly loading. + +Before the initial release is published, use the local-package workflow below rather than assuming version 1.0.0 is available on NuGet.org. + +## Installation and quick start + +In an F# notebook, reference the package in one cell: + +```fsharp +#r "nuget: Plotly.NET.Verso, 1.0.0" +``` + +Register its formatter in the next cell: + +```text +#!extension Plotly.NET.Verso 1.0.0 +``` + +Then leave a chart as a cell's final expression: + +```fsharp +open Plotly.NET + +Chart.Point(x = [0; 1; 2; 3], y = [1; 4; 9; 16]) +|> Chart.withTitle "Squares" +``` + +Verso may ask you to approve the extension package. Referencing it with `#r` alone does not register the formatter. Installing and enabling it through Verso's Extensions panel is another supported registration path. + +## Online and offline charts + +The default loads the Plotly CDN version bundled with the chart's Plotly.NET assembly. Loading is asynchronous and shared between outputs in the same page. For offline chart rendering, set `Full`: + +```fsharp +Chart.Point(x = [0; 1; 2], y = [1; 4; 9]) +|> Chart.withDisplayOptions ( + DisplayOptions.init(PlotlyJSReference = PlotlyJSReference.Full) +) +``` + +Each embedded output includes several megabytes of Plotly JavaScript so that saved charts can render without downloading it. Verso itself and any additional resources explicitly added to the chart can have their own network requirements. + +| Reference option | Verso behavior | +|---|---| +| Default / absent | Load the CDN for the chart's Plotly.NET version | +| `CDN url` | Use the supplied JavaScript URL | +| `Full` | Embed the originating Plotly.NET assembly's JavaScript | +| `NoReference` | Use already loaded Plotly or a pending extension load; never start a download | +| `Require` | Display a migration message: use `CDN` or `Full` | + +`Plotly.NET.Verso.Formatters.toVersoHTML chart` returns the notebook HTML fragment. `toVersoHTMLOffline chart` forces embedded Plotly and returns a complete HTML document suitable for saving to a file. Both retain chart data, layout, config and descriptions without changing the original chart. + +## Moving from Polyglot Notebooks + +Use `Plotly.NET.Interactive` with .NET Interactive / Polyglot Notebooks and `Plotly.NET.Verso` with Verso. Existing chart-construction code can remain the same. Replace the notebook package reference and add the Verso registration step shown above. Replace explicit `Require` display options with `CDN` or `Full`. + +The packages remain separate so each host loads only its own adapter dependencies. Installing `Plotly.NET.Interactive` does not activate the Verso formatter. + +## Local development and troubleshooting + +From the repository root: + +```text +./build.cmd RunVersoTestsFast +./build.cmd RunVersoBrowserTestsFast +./build.cmd PackVersoIntegrationPackages +./build.cmd RunVersoIntegrationTests +``` + +Use `./build.sh` on Linux. The real-host tests use isolated caches; these targets never delete your global NuGet cache. See the [test guide](https://github.com/plotly/Plotly.NET/blob/dev/tests/README.md) for browser provisioning, artifacts and the VS Code smoke procedure. + +If a chart appears as text, check that the extension is registered and enabled and that the host is at least 1.2.3. For download failures, inspect the output error and check the configured URL or use `Full`; rerunning the cell retries a failed load. Do not install RequireJS or override Monaco's `define` function. + +Licensed under the [MIT License](https://github.com/plotly/Plotly.NET/blob/dev/LICENSE). diff --git a/src/Plotly.NET.Verso/RELEASE_NOTES.md b/src/Plotly.NET.Verso/RELEASE_NOTES.md new file mode 100644 index 00000000..de2879a6 --- /dev/null +++ b/src/Plotly.NET.Verso/RELEASE_NOTES.md @@ -0,0 +1,11 @@ +## 1.0.0 - November 12 2026 + +- Initial release of Plotly.NET.Verso +- Renders GenericChart objects as interactive Plotly charts in Verso notebooks +- Supports online (CDN) and offline (embedded) rendering modes +- Requires Verso 1.2.3; updates Abstractions and Testing to 1.2.3 +- Await Plotly loading, share concurrent loads, and report failures with retry on rerun +- Honor CDN, Full and NoReference display options; report unsupported RequireJS mode +- Format charts across isolated notebook/extension assembly contexts +- Respect formatter MIME requests and cancellation; preserve chart values and descriptions +- Add formatter, Chromium and packaged CLI/web-host tests with isolated local caches diff --git a/src/Plotly.NET.Verso/Renderer.js b/src/Plotly.NET.Verso/Renderer.js new file mode 100644 index 00000000..b93e05e5 --- /dev/null +++ b/src/Plotly.NET.Verso/Renderer.js @@ -0,0 +1,69 @@ +(function (payload) { + "use strict"; + var element = document.getElementById(payload.id); + if (!element) return; + var registry = window.__plotlyNetVerso || (window.__plotlyNetVerso = { loads: Object.create(null) }); + function available() { + return window.Plotly && typeof window.Plotly.newPlot === "function"; + } + function connected() { + return element.isConnected && document.getElementById(payload.id) === element; + } + function fail(error) { + if (!connected()) return; + element.setAttribute("data-plotly-state", "error"); + var message = document.createElement("div"); + message.setAttribute("role", "alert"); + message.textContent = "Plotly.NET: " + (error && error.message ? error.message : String(error)); + element.appendChild(message); + } + function load() { + if (payload.mode === "require") { + return Promise.reject(new Error("RequireJS references are not supported in Verso. Use PlotlyJSReference.CDN or Full.")); + } + if (available()) return Promise.resolve(window.Plotly); + if (payload.mode === "none") { + var pending = Object.keys(registry.loads).map(function (key) { return registry.loads[key]; }) + .find(function (entry) { return entry.pending; }); + return pending ? pending.promise : Promise.reject(new Error("No Plotly library is loaded. Preload Plotly or use PlotlyJSReference.CDN or Full.")); + } + if (payload.mode === "full") return Promise.reject(new Error("The embedded Plotly library did not initialize.")); + var existing = registry.loads[payload.url]; + if (existing) return existing.promise; + var entry = { pending: true }; + registry.loads[payload.url] = entry; + entry.promise = new Promise(function (resolve, reject) { + var script = document.createElement("script"); + script.charset = "utf-8"; + script.async = true; + var timer; + function finish(error) { + clearTimeout(timer); + script.onload = script.onerror = null; + entry.pending = false; + if (error) { + delete registry.loads[payload.url]; + script.remove(); + reject(error); + } else resolve(window.Plotly); + } + script.onload = function () { + finish(available() ? null : new Error("The Plotly script loaded without initializing Plotly: " + payload.url)); + }; + script.onerror = function () { + finish(new Error("Unable to load Plotly from " + payload.url + ". Check the connection or use PlotlyJSReference.Full for offline rendering.")); + }; + timer = setTimeout(function () { finish(new Error("Timed out loading Plotly from " + payload.url)); }, 30000); + script.src = payload.url; + document.head.appendChild(script); + }); + return entry.promise; + } + load().then(function (plotly) { + if (!connected()) return; + return Promise.resolve(plotly.newPlot(element, payload.chart.data, payload.chart.layout, payload.chart.config)) + .then(function () { + if (connected()) element.setAttribute("data-plotly-state", "ready"); + }); + }).catch(fail); +})(/*PAYLOAD*/); diff --git a/tests/ExtensionLibsTests/VersoBrowserTests/BrowserHarness.fs b/tests/ExtensionLibsTests/VersoBrowserTests/BrowserHarness.fs new file mode 100644 index 00000000..7d9a427f --- /dev/null +++ b/tests/ExtensionLibsTests/VersoBrowserTests/BrowserHarness.fs @@ -0,0 +1,286 @@ +module Plotly.NET.Verso.BrowserTests.BrowserHarness + +open System +open System.Collections.Concurrent +open System.IO +open System.Net +open System.Text.RegularExpressions +open System.Threading.Tasks +open Expecto +open Plotly.NET +open PuppeteerSharp + +let private repositoryRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../../..")) + +let private configuredPath name fallback = + match Environment.GetEnvironmentVariable(name) with + | null | "" -> fallback + | value -> Path.GetFullPath(value) + +let browserCacheDirectory = + configuredPath "PUPPETEER_CACHE_DIR" (Path.Combine(repositoryRoot, ".cache", "verso-browser")) + +let artifactsDirectory = + configuredPath "PLOTLY_VERSO_TEST_ARTIFACTS" (Path.Combine(__SOURCE_DIRECTORY__, "TestResults", "artifacts")) + +/// The PuppeteerSharp package pins its compatible Chrome revision. Never choose an +/// arbitrary installed browser: CI and local runs must exercise the same engine. +let installBrowser () = + task { + Directory.CreateDirectory(browserCacheDirectory) |> ignore + let fetcher = new BrowserFetcher(BrowserFetcherOptions(Path = browserCacheDirectory)) + let revision = PuppeteerSharp.BrowserData.Chrome.DefaultBuildId + let executable = fetcher.GetExecutablePath(revision) + if not (File.Exists executable) then + printfn "Installing Chromium %s in %s" revision browserCacheDirectory + try + let! _ = fetcher.DownloadAsync(revision) + () + with error -> + raise (InvalidOperationException($"Could not provision Chromium {revision}. Run the FAKE PrepareVersoBrowser target with network access and a writable PUPPETEER_CACHE_DIR. {error.Message}", error)) + if not (File.Exists executable) then + failwith $"Chromium provisioning did not produce {executable}." + printfn "Verso browser tests: Chromium %s (%s)" revision executable + return executable + } + +let private installedBrowser = lazy (installBrowser ()) + +/// Read the actual bundle from the core assembly under test, including its version. +let plotlySource = + lazy ( + let assembly = typeof.Assembly + let resourceName = + assembly.GetManifestResourceNames() + |> Array.find (fun name -> name.EndsWith($"plotly-{Globals.PLOTLYJS_VERSION}.min.js", StringComparison.Ordinal)) + use stream = assembly.GetManifestResourceStream(resourceName) + use reader = new StreamReader(stream) + reader.ReadToEnd() + ) + +let respondWithPlotly (request: IRequest) = + request.RespondAsync(ResponseData(Status = HttpStatusCode.OK, ContentType = "application/javascript", Body = plotlySource.Value)) + +let respondWithFailure (request: IRequest) = + request.RespondAsync(ResponseData(Status = HttpStatusCode.ServiceUnavailable, ContentType = "text/plain", Body = "Expected test failure")) + +let respondWithoutPlotly (request: IRequest) = + request.RespondAsync(ResponseData(Status = HttpStatusCode.OK, ContentType = "application/javascript", Body = "window.__unrelatedScriptLoaded = true;")) + +type Session = + { + Page: IPage + Requests: ConcurrentQueue + ScriptRequests: ConcurrentQueue + PageErrors: ConcurrentQueue + ConsoleErrors: ConcurrentQueue + ConsoleMessages: ConcurrentQueue + RoutingErrors: ConcurrentQueue + Inputs: ConcurrentQueue + } + +let private initializeScript = + """() => { + window.__plotlyCalls = []; + window.__unhandledRejections = []; + window.addEventListener('unhandledrejection', event => { + window.__unhandledRejections.push(String(event.reason)); + }); + // Verso >= 1.0.15 leaves Monaco's define present without define.amd. + // The adapter must neither call it nor alter it to load a UMD bundle. + window.define = function () { throw new Error('Unexpected Monaco AMD call'); }; + window.__originalDefine = window.define; + let plotly; + Object.defineProperty(window, 'Plotly', { + configurable: true, + get() { return plotly; }, + set(value) { + plotly = value; + if (value && typeof value.newPlot === 'function') { + const newPlot = value.newPlot; + value.newPlot = function (target, ...args) { + window.__plotlyCalls.push(typeof target === 'string' ? target : target.id); + return newPlot.call(this, target, ...args); + }; + } + } + }); + }""" + +/// Like a notebook output renderer, insert the fragment first and recreate script +/// nodes afterwards. Browser HTML parsing would serialize external scripts and hide +/// the asynchronous-loading regression this suite is meant to catch. +let insert (session: Session) cellId html = + task { + session.Inputs.Enqueue(cellId, html) + do! + session.Page.EvaluateFunctionAsync( + """(id, html) => { + let cell = document.getElementById(id); + if (!cell) { + cell = document.createElement('section'); + cell.id = id; + document.body.appendChild(cell); + } + cell.innerHTML = html; + for (const inert of Array.from(cell.querySelectorAll('script'))) { + const active = document.createElement('script'); + for (const attribute of inert.attributes) { + active.setAttribute(attribute.name, attribute.value); + } + active.textContent = inert.textContent; + inert.replaceWith(active); + } + }""", + [| box cellId; box html |] + ) + } + +let waitForState (session: Session) cellId state = + task { + use! _handle = + session.Page.WaitForFunctionAsync( + """(id, state) => document.getElementById(id)?.querySelector('.plotly-net-verso')?.dataset.plotlyState === state""", + WaitForFunctionOptions(Timeout = 15000), + [| box cellId; box state |] + ) + return () + } + +let assertRendered (session: Session) cellId = + task { + do! waitForState session cellId "ready" + let! rendered = + session.Page.EvaluateFunctionAsync( + """id => { + const graph = document.getElementById(id)?.querySelector('.js-plotly-plot'); + return !!graph && !!graph.querySelector('svg.main-svg') && + graph.calcdata?.length > 0 && graph._fullLayout.width > 0 && + graph._fullLayout.height > 0 && graph.getBoundingClientRect().height > 0; + }""", + [| box cellId |] + ) + Expect.isTrue rendered $"{cellId} must contain an actual visible Plotly chart, not just a ready marker." + } + +let assertError (session: Session) cellId = + task { + do! waitForState session cellId "error" + let! message = + session.Page.EvaluateFunctionAsync( + "id => document.getElementById(id).querySelector('[role=alert]')?.textContent || ''", + [| box cellId |] + ) + Expect.isFalse (String.IsNullOrWhiteSpace message) "A failed output must explain the failure visibly." + return message + } + +let assertCallCount (session: Session) expected = + task { + let! count = session.Page.EvaluateExpressionAsync("window.__plotlyCalls.length") + Expect.equal count expected "Each live output should render exactly once." + } + +let private saveFailure name (session: Session) (error: exn) = + task { + let directory = Path.Combine(artifactsDirectory, Regex.Replace(name, "[^A-Za-z0-9_-]", "_")) + Directory.CreateDirectory(directory) |> ignore + File.WriteAllText(Path.Combine(directory, "failure.txt"), error.ToString()) + File.WriteAllLines(Path.Combine(directory, "console.txt"), session.ConsoleMessages) + File.WriteAllLines(Path.Combine(directory, "page-errors.txt"), session.PageErrors) + File.WriteAllLines(Path.Combine(directory, "requests.txt"), session.Requests) + File.WriteAllLines(Path.Combine(directory, "routing-errors.txt"), session.RoutingErrors) + session.Inputs.ToArray() + |> Array.iteri (fun index (id, html) -> File.WriteAllText(Path.Combine(directory, $"input-{index}-{id}.html"), html)) + try + let! html = session.Page.GetContentAsync() + File.WriteAllText(Path.Combine(directory, "page.html"), html) + do! session.Page.ScreenshotAsync(Path.Combine(directory, "page.png"), ScreenshotOptions(FullPage = true)) + with captureError -> + File.WriteAllText(Path.Combine(directory, "capture-error.txt"), captureError.ToString()) + eprintfn "Browser failure artifacts: %s" directory + } + +let run name (routeScript: IRequest -> int -> Task) allowResourceFailure (body: Session -> Task) = + async { + let! executable = installedBrowser.Value |> Async.AwaitTask + let launch = + LaunchOptions( + ExecutablePath = executable, + Headless = true, + Timeout = 60000, + Args = [| "--no-sandbox"; "--disable-dev-shm-usage" |] + ) + use! browser = Puppeteer.LaunchAsync(launch) |> Async.AwaitTask + use! page = browser.NewPageAsync() |> Async.AwaitTask + let session = + { + Page = page + Requests = ConcurrentQueue() + ScriptRequests = ConcurrentQueue() + PageErrors = ConcurrentQueue() + ConsoleErrors = ConcurrentQueue() + ConsoleMessages = ConcurrentQueue() + RoutingErrors = ConcurrentQueue() + Inputs = ConcurrentQueue() + } + page.PageError.Add(fun event -> session.PageErrors.Enqueue event.Message) + page.Console.Add(fun event -> + session.ConsoleMessages.Enqueue($"{event.Message.Type}: {event.Message.Text}") + if event.Message.Type = ConsoleType.Error then session.ConsoleErrors.Enqueue event.Message.Text) + page.AddRequestInterceptor(Func(fun request -> + task { + try + if request.Url.StartsWith("data:", StringComparison.Ordinal) + || request.Url.StartsWith("blob:", StringComparison.Ordinal) then + // Plotly probes browser image support using an embedded WebP. + // These in-memory resources do not constitute network access. + do! request.ContinueAsync() + else + session.Requests.Enqueue(request.Url) + if request.ResourceType = ResourceType.Script then + session.ScriptRequests.Enqueue(request.Url) + do! routeScript request session.ScriptRequests.Count + else + // No scenario is permitted to depend on outside network access. + do! request.AbortAsync() + with error -> + session.RoutingErrors.Enqueue(error.ToString()) + return raise error + } :> Task)) + try + do! page.SetRequestInterceptionAsync(true) |> Async.AwaitTask + do! page.SetContentAsync("") |> Async.AwaitTask + do! page.EvaluateFunctionAsync(initializeScript) |> Async.AwaitTask + do! body session |> Async.AwaitTask + Expect.isEmpty session.PageErrors "The output must not throw browser JavaScript errors." + Expect.isEmpty session.RoutingErrors "The deterministic asset router must not fail." + let unexpectedConsoleErrors = + session.ConsoleErrors.ToArray() + |> Array.filter (fun message -> not (allowResourceFailure && message.Contains("Failed to load resource", StringComparison.Ordinal))) + Expect.isEmpty unexpectedConsoleErrors "The output must not log unexpected console errors." + let! unhandled = page.EvaluateExpressionAsync("window.__unhandledRejections") |> Async.AwaitTask + Expect.isEmpty unhandled "Rejected render/load promises must be handled." + let! unchanged = page.EvaluateExpressionAsync("window.define === window.__originalDefine && !window.define.amd && typeof window.requirejs === 'undefined'") |> Async.AwaitTask + Expect.isTrue unchanged "Rendering must preserve the corrected host's Monaco loader and avoid RequireJS." + with error -> + do! saveFailure name session error |> Async.AwaitTask + return raise error + } + +let servePlotly request _ = respondWithPlotly request + +let rejectNetwork (request: IRequest) _ = request.AbortAsync() + +/// A gate, not a sleep, proves that the output is waiting while the asset is absent. +type DelayedScript() = + let started = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let release = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + member _.Started = started.Task.WaitAsync(TimeSpan.FromSeconds 15.) + member _.Release() = release.TrySetResult() |> ignore + member _.Route(request: IRequest, _index: int) : Task = + task { + started.TrySetResult() |> ignore + do! release.Task.WaitAsync(TimeSpan.FromSeconds 15.) + do! respondWithPlotly request + } diff --git a/tests/ExtensionLibsTests/VersoBrowserTests/Main.fs b/tests/ExtensionLibsTests/VersoBrowserTests/Main.fs new file mode 100644 index 00000000..312a9292 --- /dev/null +++ b/tests/ExtensionLibsTests/VersoBrowserTests/Main.fs @@ -0,0 +1,11 @@ +module Plotly.NET.Verso.BrowserTests.Main + +open Expecto + +[] +let main arguments = + if arguments = [| "--install-browser" |] then + BrowserHarness.installBrowser().GetAwaiter().GetResult() |> ignore + 0 + else + Tests.runTestsInAssemblyWithCLIArgs [] arguments diff --git a/tests/ExtensionLibsTests/VersoBrowserTests/RenderingTests.fs b/tests/ExtensionLibsTests/VersoBrowserTests/RenderingTests.fs new file mode 100644 index 00000000..585aaf40 --- /dev/null +++ b/tests/ExtensionLibsTests/VersoBrowserTests/RenderingTests.fs @@ -0,0 +1,258 @@ +module Plotly.NET.Verso.BrowserTests.RenderingTests + +open System.Threading.Tasks +open Expecto +open Giraffe.ViewEngine +open Plotly.NET +open Plotly.NET.Verso +open BrowserHarness + +let private assetUrl = $"https://assets.example.invalid/plotly-{Globals.PLOTLYJS_VERSION}.min.js?fixture=verso" + +let private lineChart () = + Chart.Line(x = [ 1; 2; 3 ], y = [ 4; 6; 5 ], UseDefaults = false) + +let private reference mode chart = + chart |> GenericChart.setDisplayOptions(DisplayOptions.init(PlotlyJSReference = mode)) + +let private online () = lineChart () |> reference (CDN assetUrl) + +let private render session cell chart = insert session cell (Formatters.toVersoHTML chart) + +let private browserTest name body = testCaseAsync name (run name servePlotly false body) + +let private noNetworkTest name body = testCaseAsync name (run name rejectNetwork false body) + +let private delayedTest name body = + testCaseAsync name (async { + let gate = DelayedScript() + return! run name (fun request index -> gate.Route(request, index)) false (body gate) + }) + +let private graphId (session: Session) cellId = + session.Page.EvaluateFunctionAsync( + "id => document.getElementById(id).querySelector('.plotly-net-verso').id", + [| box cellId |] + ) + +let private assertRequests (session: Session) expected = + Expect.sequenceEqual (session.Requests.ToArray()) expected "Only the specified locally fulfilled Plotly requests are permitted." + +[] +let rendering = + testSequencedGroup "VersoBrowser" ( + testList "Verso browser rendering" [ + delayedTest "CDN waits for a delayed script before drawing" (fun gate session -> task { + do! render session "first" (online ()) + do! gate.Started + let! waiting = session.Page.EvaluateExpressionAsync("document.querySelector('.plotly-net-verso').dataset.plotlyState === 'loading' && typeof window.Plotly === 'undefined'") + Expect.isTrue waiting "The cell must wait for the external asset instead of calling an absent Plotly." + do! assertCallCount session 0 + gate.Release() + do! assertRendered session "first" + do! assertCallCount session 1 + let! version = session.Page.EvaluateExpressionAsync("window.Plotly.version") + Expect.equal version Globals.PLOTLYJS_VERSION "The test must execute the bundle shipped by this core assembly." + assertRequests session [ assetUrl ] + }) + + delayedTest "Concurrent cells share one load and retain distinct charts" (fun gate session -> task { + do! render session "first" (online ()) + do! gate.Started + let second = Chart.Point(x = [ 8; 9 ], y = [ 10; 11 ], UseDefaults = false) |> reference (CDN assetUrl) + do! render session "second" second + do! assertCallCount session 0 + gate.Release() + do! assertRendered session "first" + do! assertRendered session "second" + let! firstId = graphId session "first" + let! secondId = graphId session "second" + Expect.notEqual firstId secondId "Independent cells must have distinct chart IDs." + let! dataPreserved = session.Page.EvaluateExpressionAsync("document.querySelector('#first .js-plotly-plot').data[0].x.join(',') === '1,2,3' && document.querySelector('#second .js-plotly-plot').data[0].y.join(',') === '10,11'") + Expect.isTrue dataPreserved "Concurrent output must not overwrite another chart's data." + do! assertCallCount session 2 + assertRequests session [ assetUrl ] + }) + + browserTest "Cached loading and rerunning a cell draw exactly once per output" (fun session -> task { + do! render session "first" (online ()) + do! assertRendered session "first" + let! previousId = graphId session "first" + do! render session "second" (online ()) + do! assertRendered session "second" + do! render session "first" (online ()) + do! assertRendered session "first" + let! nextId = graphId session "first" + Expect.notEqual previousId nextId "A rerun should use a fresh output identity." + do! session.Page.EvaluateFunctionAsync("() => { for (const script of document.querySelectorAll('script[src]')) script.dispatchEvent(new Event('load')); }") + do! session.Page.EvaluateFunctionAsync("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))") + do! assertCallCount session 3 + let! graphCount = session.Page.EvaluateExpressionAsync("document.querySelectorAll('.js-plotly-plot').length") + Expect.equal graphCount 2 "A rerun must replace the old graph rather than append a duplicate." + assertRequests session [ assetUrl ] + }) + + delayedTest "Removing a waiting cell does not render into detached output" (fun gate session -> task { + do! render session "removed" (online ()) + do! gate.Started + let! removedId = graphId session "removed" + do! render session "remaining" (online ()) + do! session.Page.EvaluateFunctionAsync("() => document.getElementById('removed').remove()") + gate.Release() + do! assertRendered session "remaining" + do! assertCallCount session 1 + let! ignored = session.Page.EvaluateFunctionAsync("id => !document.getElementById(id) && !window.__plotlyCalls.includes(id)", [| box removedId |]) + Expect.isTrue ignored "Resolving a pending load must not resurrect or render a removed chart." + assertRequests session [ assetUrl ] + }) + + testCaseAsync "A failed CDN load is visible and a later output retries" ( + run "A failed CDN load is visible and a later output retries" + (fun request index -> if index = 1 then respondWithFailure request else respondWithPlotly request) + true + (fun session -> task { + do! render session "failed" (online ()) + let! message = assertError session "failed" + Expect.isTrue (message.Contains("Plotly") || message.Contains("plotly")) "Loading failures should identify the missing renderer." + do! assertCallCount session 0 + do! render session "retry" (online ()) + do! assertRendered session "retry" + do! assertCallCount session 1 + assertRequests session [ assetUrl; assetUrl ] + }) + ) + + testCaseAsync "A script without Plotly reports initialization failure and permits retry" ( + run "A script without Plotly reports initialization failure and permits retry" + (fun request index -> if index = 1 then respondWithoutPlotly request else respondWithPlotly request) + false + (fun session -> task { + do! render session "wrong-script" (online ()) + let! message = assertError session "wrong-script" + Expect.stringContains message "Plotly" "A successful HTTP response alone must not be considered a loaded renderer." + do! assertCallCount session 0 + do! render session "correct-script" (online ()) + do! assertRendered session "correct-script" + do! assertCallCount session 1 + assertRequests session [ assetUrl; assetUrl ] + }) + ) + + noNetworkTest "Full reference renders with every network request blocked" (fun session -> task { + do! render session "offline" (lineChart () |> reference Full) + do! assertRendered session "offline" + do! assertCallCount session 1 + assertRequests session [] + }) + + noNetworkTest "The complete offline document survives notebook-style insertion" (fun session -> task { + do! insert session "offline-document" (Formatters.toVersoHTMLOffline (online ())) + do! assertRendered session "offline-document" + do! assertCallCount session 1 + assertRequests session [] + }) + + noNetworkTest "NoReference reuses a preloaded Plotly without downloading" (fun session -> task { + do! session.Page.EvaluateExpressionAsync(plotlySource.Value) + do! render session "preloaded" (lineChart () |> reference NoReference) + do! assertRendered session "preloaded" + do! assertCallCount session 1 + assertRequests session [] + }) + + delayedTest "NoReference can join an existing pending adapter load" (fun gate session -> task { + do! render session "loader" (online ()) + do! gate.Started + do! render session "consumer" (lineChart () |> reference NoReference) + do! assertCallCount session 0 + gate.Release() + do! assertRendered session "loader" + do! assertRendered session "consumer" + do! assertCallCount session 2 + assertRequests session [ assetUrl ] + }) + + noNetworkTest "NoReference without a loaded renderer reports an actionable error" (fun session -> task { + do! render session "missing" (lineChart () |> reference NoReference) + let! message = assertError session "missing" + Expect.isTrue (message.Contains("CDN") || message.Contains("Full")) "The error should explain how to provide Plotly." + do! assertCallCount session 0 + assertRequests session [] + }) + + noNetworkTest "Require reports migration guidance without loading RequireJS" (fun session -> task { + do! render session "require" (lineChart () |> reference (Require "https://assets.example.invalid/plotly")) + let! message = assertError session "require" + Expect.stringContains message "CDN" "Require users should be directed to the supported CDN mode." + Expect.stringContains message "Full" "Require users should be directed to the supported offline mode." + do! assertCallCount session 0 + assertRequests session [] + }) + + browserTest "An absent reference uses the originating runtime CDN" (fun session -> task { + let chart = lineChart () |> GenericChart.setDisplayOptions(DisplayOptions.init()) + do! render session "default" chart + do! assertRendered session "default" + assertRequests session [ $"https://cdn.plot.ly/plotly-{Globals.PLOTLYJS_VERSION}.min.js" ] + }) + + browserTest "Rendering preserves data layout configuration descriptions and head tags" (fun session -> task { + let chart = + lineChart () + |> Chart.withTitle "A chart with explicit options" + |> Chart.withSize(Width = 480, Height = 320) + |> Chart.withConfig(Config.init(Responsive = false, DisplayModeBar = false)) + |> GenericChart.setDisplayOptions( + DisplayOptions.init( + PlotlyJSReference = CDN assetUrl, + AdditionalHeadTags = [ script [] [ rawText "window.__additionalHeadTagRan = true;" ] ], + ChartDescription = [ p [ _class "chart-description" ] [ str "A retained chart description" ] ] + ) + ) + do! render session "configured" chart + do! assertRendered session "configured" + let! retained = session.Page.EvaluateExpressionAsync("""(() => { + const graph = document.querySelector('#configured .js-plotly-plot'); + return graph.data[0].x.join(',') === '1,2,3' && graph.data[0].y.join(',') === '4,6,5' && + graph.layout.title.text === 'A chart with explicit options' && + graph._fullLayout.width === 480 && graph._fullLayout.height === 320 && + graph._context.responsive === false && graph._context.displayModeBar === false && + document.querySelector('#configured .chart-description').textContent === 'A retained chart description' && + window.__additionalHeadTagRan === true; + })()""") + Expect.isTrue retained "The adapter must preserve the chart's input options and additional content." + do! assertCallCount session 1 + assertRequests session [ assetUrl ] + }) + + browserTest "A combined chart renders every trace" (fun session -> task { + let chart = + Chart.combine [ lineChart (); Chart.Point(x = [ 2; 4 ], y = [ 1; 3 ], UseDefaults = false) ] + |> reference (CDN assetUrl) + do! render session "combined" chart + do! assertRendered session "combined" + let! traces = session.Page.EvaluateExpressionAsync("document.querySelector('#combined .js-plotly-plot').calcdata.length") + Expect.equal traces 2 "A MultiChart must retain all traces when formatted." + do! assertCallCount session 1 + assertRequests session [ assetUrl ] + }) + + noNetworkTest "Render rejections become visible output errors" (fun session -> task { + do! session.Page.EvaluateFunctionAsync("() => { window.Plotly = { newPlot() { return Promise.reject(new Error('Deliberate render failure')); } }; }") + do! render session "render-failure" (lineChart () |> reference NoReference) + let! message = assertError session "render-failure" + Expect.stringContains message "Deliberate render failure" "The output should preserve the useful renderer failure." + do! assertCallCount session 1 + assertRequests session [] + }) + + noNetworkTest "Synchronous render exceptions become visible output errors" (fun session -> task { + do! session.Page.EvaluateFunctionAsync("() => { window.Plotly = { newPlot() { throw new Error('Deliberate synchronous failure'); } }; }") + do! render session "render-throw" (lineChart () |> reference NoReference) + let! message = assertError session "render-throw" + Expect.stringContains message "Deliberate synchronous failure" "Synchronous failures must not escape the output's error handler." + do! assertCallCount session 1 + assertRequests session [] + }) + ] + ) diff --git a/tests/ExtensionLibsTests/VersoBrowserTests/VersoBrowserTests.fsproj b/tests/ExtensionLibsTests/VersoBrowserTests/VersoBrowserTests.fsproj new file mode 100644 index 00000000..7736c0cc --- /dev/null +++ b/tests/ExtensionLibsTests/VersoBrowserTests/VersoBrowserTests.fsproj @@ -0,0 +1,23 @@ + + + Exe + net10.0 + false + false + false + + + + + + + + + + + + + + + + diff --git a/tests/ExtensionLibsTests/VersoTests/ForeignChartFactory.fs b/tests/ExtensionLibsTests/VersoTests/ForeignChartFactory.fs new file mode 100644 index 00000000..9e019d18 --- /dev/null +++ b/tests/ExtensionLibsTests/VersoTests/ForeignChartFactory.fs @@ -0,0 +1,25 @@ +module Tests.ForeignChartFactory + +open Giraffe.ViewEngine +open Plotly.NET + +/// Invoked by reflection after this assembly and its chart dependencies are loaded in another ALC. +let create (mode: string) (multiple: bool) : obj = + let first = Chart.Point(x = [ 1; 2 ], y = [ 3; 4 ], UseDefaults = false) + let chart = + if multiple then + [ first; Chart.Line(x = [ 1; 2 ], y = [ 5; 6 ], UseDefaults = false) ] |> Chart.combine + else first + let reference = + match mode with + | "full" -> Full + | "none" -> NoReference + | "require" -> Require "https://example.invalid/plotly" + | _ -> CDN "https://example.invalid/plotly.js" + chart + |> GenericChart.setDisplayOptions ( + DisplayOptions.init ( + PlotlyJSReference = reference, + AdditionalHeadTags = [ style [] [ rawText ".foreign-chart { color: red; }" ] ], + ChartDescription = [ p [ _class "foreign-chart" ] [ str "Foreign description" ] ])) + |> box diff --git a/tests/ExtensionLibsTests/VersoTests/Main.fs b/tests/ExtensionLibsTests/VersoTests/Main.fs new file mode 100644 index 00000000..ae3a3e1b --- /dev/null +++ b/tests/ExtensionLibsTests/VersoTests/Main.fs @@ -0,0 +1,7 @@ +module Plotly.NET.Verso.Tests + +open Expecto + +[] +let main argv = + Tests.runTestsInAssemblyWithCLIArgs [] argv diff --git a/tests/ExtensionLibsTests/VersoTests/VersoFormatterTests.fs b/tests/ExtensionLibsTests/VersoTests/VersoFormatterTests.fs new file mode 100644 index 00000000..16e15b64 --- /dev/null +++ b/tests/ExtensionLibsTests/VersoTests/VersoFormatterTests.fs @@ -0,0 +1,198 @@ +module Tests.VersoFormatterTests + +open System +open System.IO +open System.Reflection +open System.Runtime.Loader +open System.Text.RegularExpressions +open System.Threading +open Expecto +open Giraffe.ViewEngine +open Newtonsoft.Json +open Newtonsoft.Json.Linq +open Plotly.NET +open Plotly.NET.Verso +open Verso.Abstractions +open Verso.Testing.Stubs +open TestUtils.HtmlCodegen + +let private formatter = GenericChartFormatter() :> IDataFormatter +let private context () = StubFormatterContext() +let private chart () = Chart.Line(x = [ 1; 2; 3 ], y = [ 4; 6; 5 ], UseDefaults = false) + +type private CustomDisplayOptions() = + inherit DisplayOptions() + +let private withReference reference chart = + chart |> GenericChart.setDisplayOptions (DisplayOptions.init (PlotlyJSReference = reference)) + +let private parseJson (json: string) = + use text = new StringReader(json) + use reader = new JsonTextReader(text) + reader.DateParseHandling <- DateParseHandling.None + JObject.Load(reader) + +let private payload (html: string) = + let result = Regex.Match(html, @"\}\)\((\{""id"".*)\);\s*", RegexOptions.Singleline) + Expect.isTrue result.Success "The renderer carries a JSON payload." + parseJson result.Groups[1].Value + +let private assertPayload chart html = + let expected = GenericChart.toJson chart |> parseJson + let actual = (payload html).["chart"] + Expect.isTrue (JToken.DeepEquals(expected, actual)) "The originating serializer supplies all data, layout, and config." + +[] +let contractTests = + testList "Verso.Formatter contract" [ + testCase "Object prefilter permits isolated chart types while CanFormat remains selective" (fun () -> + Expect.contains formatter.SupportedTypes typeof "Verso's type prefilter must accept foreign charts." + Expect.isTrue (formatter.CanFormat(chart (), context ())) "Chart accepted." + for value in [ null; box "not a chart"; obj (); box 42 ] do + Expect.isFalse (formatter.CanFormat(value, context ())) "Non-chart values are not captured." + let plain = StubFormatterContext(MimeType = "text/plain") + Expect.isFalse (formatter.CanFormat(chart (), plain)) "Plain text remains available to other formatters." + Expect.equal formatter.Priority 100 "Chart formatter priority retained.") + + testCase "Extension metadata is discoverable and follows assembly version" (fun () -> + let extension = GenericChartFormatter() :> IExtension + let version = typeof.Assembly.GetCustomAttribute().InformationalVersion.Split('+').[0] + Expect.equal extension.Version version "Extension and package version agree." + Expect.equal extension.ExtensionId "net.plotly.plotly-net-verso" "Stable extension identity." + Expect.isNotNull (typeof.GetCustomAttribute()) "Verso can discover the extension." + extension.OnUnloadedAsync().GetAwaiter().GetResult()) + + testAsync "FormatAsync returns chart HTML" { + let source = chart () + let! output = formatter.FormatAsync(source, context ()) |> Async.AwaitTask + Expect.equal output.MimeType "text/html" "HTML output." + Expect.isFalse output.IsError "A valid chart is not an error." + Expect.stringContains output.Content "class=\"plotly-net-verso\"" "Chart container." + assertPayload source output.Content + } + + testAsync "Invalid values return a useful error output" { + let! output = formatter.FormatAsync("invalid", context ()) |> Async.AwaitTask + Expect.isTrue output.IsError "Formatting failure is explicit." + Expect.stringContains output.Content "role=\"alert\"" "Error is readable in the cell." + Expect.stringContains output.Content "Expected a Plotly.NET GenericChart" "Error identifies the problem." + } + + testCase "Cancellation returns a canceled task" (fun () -> + use cancellation = new CancellationTokenSource() + cancellation.Cancel() + let ctx = StubFormatterContext(CancellationToken = cancellation.Token) + let task = formatter.FormatAsync(chart (), ctx) + Expect.throwsT (fun () -> task.GetAwaiter().GetResult() |> ignore) "Cancellation is not converted into a chart error." + Expect.isTrue task.IsCanceled "Task preserves cancellation state.") + ] + +[] +let renderingTests = + testList "Verso.Formatters" [ + testCase "Default and custom CDN references use the asynchronous renderer" (fun () -> + let source = chart () + let html = Formatters.toVersoHTML source + let data = payload html + Expect.equal (string data["mode"]) "cdn" "CDN default." + Expect.equal (string data["url"]) $"https://cdn.plot.ly/plotly-{Globals.PLOTLYJS_VERSION}.min.js" "Core's bundled version determines the CDN." + Expect.isFalse (html.Contains("requirejs.config")) "No RequireJS dependency." + Expect.isFalse (html.Contains("cdnjs.cloudflare.com")) "No fallback loader download." + assertPayload source html + let custom = source |> withReference (CDN "https://example.invalid/custom-plotly.js?key=a&b=2") + Expect.equal (string (payload (Formatters.toVersoHTML custom)).["url"]) "https://example.invalid/custom-plotly.js?key=a&b=2" "Custom CDN is preserved exactly.") + + testCase "Absent and explicit NoReference are distinct" (fun () -> + let absent = chart () |> GenericChart.setDisplayOptions (DisplayOptions()) + Expect.equal (string (payload (Formatters.toVersoHTML absent)).["mode"]) "cdn" "Missing reference defaults to CDN." + let explicit = chart () |> withReference NoReference + Expect.equal (string (payload (Formatters.toVersoHTML explicit)).["mode"]) "none" "Explicit NoReference does not start a download.") + + testCase "Require carries an explicit unsupported mode" (fun () -> + let html = chart () |> withReference (Require "https://example.invalid/plotly") |> Formatters.toVersoHTML + Expect.equal (string (payload html).["mode"]) "require" "Legacy mode is identified." + Expect.stringContains html "RequireJS references are not supported" "The renderer provides an actionable error.") + + testCase "Full is automatically offline and explicit offline helper overrides CDN without mutation" (fun () -> + let source = chart () + let originalReference = source |> GenericChart.getDisplayOptions |> DisplayOptions.getPlotlyReference + let full = source |> withReference Full + let automatic = Formatters.toVersoHTML full + Expect.equal (string (payload automatic).["mode"]) "full" "Full automatically embeds Plotly." + Expect.stringContains automatic (getFullPlotlyJS ()) "Actual bundled asset is embedded." + let document = Formatters.toVersoHTMLOffline source + Expect.stringContains document "" "Offline helper retains complete document contract." + Expect.stringContains document (getFullPlotlyJS ()) "Explicit offline helper embeds actual asset." + Expect.equal (source |> GenericChart.getDisplayOptions |> DisplayOptions.getPlotlyReference) originalReference "Input options are unchanged." + assertPayload source document) + + testCase "Data layout config and defaults survive formatting" (fun () -> + let sources = [ + chart () |> Chart.withTitle "Custom title" |> Chart.withSize (512, 333) |> Chart.withConfig (Config.init (Responsive = false)) + Chart.Point(x = [ 1; 2 ], y = [ 3; 4 ]) + Chart.Point(x = [ "2026-11-12T14:15:16.1234567+05:45" ], y = [ 3 ], UseDefaults = false) + chart () |> GenericChart.setDisplayOptions (CustomDisplayOptions()) + [ chart (); Chart.Point(x = [ 1; 2 ], y = [ 8; 9 ], UseDefaults = false) ] |> Chart.combine + ] + for source in sources do + let before = GenericChart.toJson source + Formatters.toVersoHTML source |> assertPayload source + Expect.equal (GenericChart.toJson source) before "Formatting leaves chart payload intact.") + + testCase "Descriptions head tags and document metadata are preserved" (fun () -> + let source = chart () |> Chart.withDisplayOptions ( + DisplayOptions.init ( + DocumentTitle = "Title <&>", DocumentDescription = "Description <&>", DocumentCharset = "UTF-8", + AdditionalHeadTags = [ style [] [ rawText ".test-chart { color: red; }" ] ], + ChartDescription = [ p [ _class "test-chart" ] [ str "Description <&>" ] ])) + let fragment = Formatters.toVersoHTML source + Expect.stringContains fragment ".test-chart { color: red; }" "Additional head content retained." + Expect.stringContains fragment "Description <&>" "Description uses source Giraffe escaping." + Expect.isLessThan (fragment.IndexOf("Title <&>" "Document title escaped and retained.") + + testCase "Payload cannot prematurely close its script and IDs are unique" (fun () -> + let title = "" + let source = chart () |> Chart.withTitle title + let first = Formatters.toVersoHTML source + let second = Formatters.toVersoHTML source + Expect.isFalse (first.Contains(title)) "Chart text is escaped for inline script context." + assertPayload source first + Expect.notEqual (string (payload first).["id"]) (string (payload second).["id"]) "Each output gets a distinct DOM identifier.") + ] + +type private ChartLoadContext() = + inherit AssemblyLoadContext("Verso chart test " + Guid.NewGuid().ToString("N"), true) + let directory = Path.GetDirectoryName(typeof.Assembly.Location) + override this.Load(name) = + if name.Name = "Verso.Abstractions" then typeof.Assembly + else + let path = Path.Combine(directory, name.Name + ".dll") + if File.Exists(path) then this.LoadFromAssemblyPath(path) else null + +[] +let isolatedTests = + testList "Verso.Isolated chart assembly" [ + for multiple in [ false; true ] do + testCase $"Formats isolated chart; multiple={multiple}" (fun () -> + let loadContext = ChartLoadContext() + try + let assembly = loadContext.LoadFromAssemblyPath(Assembly.GetExecutingAssembly().Location) + let factory = assembly.GetType("Tests.ForeignChartFactory").GetMethod("create") + let source = factory.Invoke(null, [| box "cdn"; box multiple |]) + Expect.isFalse (source :? GenericChart) "Fixture genuinely crosses the assembly type boundary." + Expect.isTrue (formatter.SupportedTypes |> Seq.exists (fun t -> t.IsInstanceOfType(source))) "Host prefilter permits value." + Expect.isTrue (formatter.CanFormat(source, context ())) "Foreign chart accepted." + let output = formatter.FormatAsync(source, context ()).GetAwaiter().GetResult() + Expect.isFalse output.IsError output.Content + let chartData = (payload output.Content).["chart"].["data"] + Expect.equal ((chartData :?> JArray).Count) (if multiple then 2 else 1) "All foreign traces retained." + Expect.stringContains output.Content "Foreign description" "Foreign description rendered in its own Giraffe assembly." + Expect.stringContains output.Content ".foreign-chart { color: red; }" "Foreign head tags retained." + let offline = factory.Invoke(null, [| box "full"; box multiple |]) + let offlineOutput = formatter.FormatAsync(offline, context ()).GetAwaiter().GetResult() + Expect.isFalse offlineOutput.IsError "Foreign offline chart succeeds." + Expect.stringContains offlineOutput.Content (getFullPlotlyJS ()) "Foreign assembly's embedded Plotly is included." + finally loadContext.Unload()) + ] diff --git a/tests/ExtensionLibsTests/VersoTests/VersoTests.fsproj b/tests/ExtensionLibsTests/VersoTests/VersoTests.fsproj new file mode 100644 index 00000000..7af6da84 --- /dev/null +++ b/tests/ExtensionLibsTests/VersoTests/VersoTests.fsproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + false + false + false + + + + + + + + + + + + + + + + + + + + diff --git a/tests/README.md b/tests/README.md index ab9d5fdf..d207dbe6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,6 +32,40 @@ Available test targets are: - `RunTestsExtensionLibs` - runs all tests in the `ExtensionLibTests` folder - `RunTestsJSTests` - runs all tests in the `JSTests` folder +### Verso tests + +The Verso adapter has three test layers: + +| Suite | What it verifies | FAKE targets | +|---|---|---| +| `VersoTests` | Formatter contracts, options and charts crossing isolated assembly contexts | `RunVersoTests`, `RunVersoTestsFast` | +| `VersoBrowserTests` | Actual chart rendering with delayed/failed script loads and embedded offline output | `RunVersoBrowserTests`, `RunVersoBrowserTestsFast` | +| `VersoIntegrationTests` | Locally packed core/adapter packages in the pinned Verso CLI and its web UI | `RunVersoIntegrationTests` | + +Use `./build.cmd ` on Windows or `./build.sh ` on Linux. The first two suites are also part of `RunTestsExtensionLibs[Fast]` and `RunTestsAll[Fast]`. The real-host suite is a separate Linux CI job and can also run locally on Windows. All test projects use .NET 10; the shipped Verso adapter targets .NET 8. + +Provision the pinned Chromium revision with `PrepareVersoBrowser`. Local browser tests provision it if absent; a download/launch error fails the suite instead of skipping it. Set `PUPPETEER_CACHE_DIR` to share the browser cache across runs. Linux needs Chromium's shared libraries (see the CI workflow or devcontainer Dockerfile). + +Browser tests intercept Plotly downloads with the actual bundled script and dynamically insert formatter output as notebook cells do. They do not depend on CDN availability. Failure screenshots, HTML and browser logs go to `PLOTLY_VERSO_TEST_ARTIFACTS`, or the suite's `TestResults/artifacts` directory. FAKE writes TRX results into each project's `TestResults` directory. + +`PackVersoIntegrationPackages` builds and packs only the core and Verso adapter without publishing or interactive release prompts. `RunVersoIntegrationTests` additionally runs the packaged-host smoke. Generated feeds, manifests, notebooks and diagnostics live under ignored `tests/VersoTests/artifacts`. The harness isolates NuGet and Verso's own temporary package cache, verifies the hashes of loaded assemblies against the fresh packages, and terminates the local server when finished. The `Repack.ps1` helper delegates to FAKE and does not clear the user's global NuGet cache. + +The host suite needs network access to restore the pinned Verso CLI and dependencies. The web host itself also loads Monaco and Mermaid externally. Offline chart checks run after host initialization and allow those exact host asset paths while blocking external chart requests. + +#### VS Code release smoke + +Use the installed Verso extension version 1.2.3 or later compatible 1.x release. `PackVersoIntegrationPackages` generates `tests/VersoTests/artifacts/test.verso` with the absolute feed path and package version. Open that generated copy. Its first cell uses **C#** to register the local source with Verso's shared extension resolver: + +```text +#i "nuget: " +``` + +Register `#!extension Plotly.NET.Verso ` in the next **F#** cell; this also makes the package assemblies available to the kernel. The generated C# source-registration cell is an explicit setup choice, not a requirement that notebooks use C#. F# `#i` is supported, and you can keep an F# setup that successfully resolves the local extension. If `#!extension` cannot find the local package, the generated C# cell explicitly registers its feed with the shared resolver. Use a fresh process temporary directory for a repeated local-package smoke: Verso's extraction cache can otherwise shadow a rebuilt package with the same version. The automated harness isolates this directory and verifies provenance. + +Verify first render, two chart cells, rerunning a cell, a `Full` chart with Plotly network access unavailable, and saving/closing/reopening outputs. Confirm there are no Monaco/AMD errors or duplicate charts. Record the VS Code/Verso versions and observed results in `plans/VersoExtension.md`; CLI/web-host tests do not substitute for this host-specific check. + +Before committing, run full clean `RunTestsAll` and the dedicated `RunVersoIntegrationTests` target. CI publishes failure diagnostics for the Windows/Linux suites and host artifacts for the integration job. + ### Using Visual Studio Test Explorer You can also run tests from within Visual Studio using the Test Explorer. @@ -71,4 +105,4 @@ Testing usage of the core F# API from C# ### JSTests -`JSTests` contains all UnitTests that run on node.js. The main application here is the validation of generated JSON via the `Plotly.validate` function in the `plotly.js` library. \ No newline at end of file +`JSTests` contains all UnitTests that run on node.js. The main application here is the validation of generated JSON via the `Plotly.validate` function in the `plotly.js` library. diff --git a/tests/VersoTests/Integration/BrowserTests.fs b/tests/VersoTests/Integration/BrowserTests.fs new file mode 100644 index 00000000..1ed84e47 --- /dev/null +++ b/tests/VersoTests/Integration/BrowserTests.fs @@ -0,0 +1,210 @@ +namespace Plotly.NET.Verso.IntegrationTests + +open System +open System.Collections.Concurrent +open System.Diagnostics +open System.IO +open System.Net +open System.Text.Json.Nodes +open System.Threading.Tasks +open PuppeteerSharp +open Infrastructure + +module BrowserTests = + // Opening a notebook can prompt before its outputs are mounted, as can its first execution. + // This process owns the generated fixture and approves only its exact extension package. + let private approveFixture = "for (const modal of document.querySelectorAll('.verso-modal')) { if (modal.textContent.includes('Plotly.NET.Verso') && modal.querySelector('.verso-modal-btn--primary')) modal.querySelector('.verso-modal-btn--primary').click(); }" + + let private wait (page: IPage) expression = task { + let! _ = page.WaitForFunctionAsync("() => { " + approveFixture + " return (" + expression + ")(); }", WaitForFunctionOptions(Timeout = 90000)) + return () + } + + let private waitCharts (page: IPage) count = + wait page $"() => {{ const charts = [...document.querySelectorAll('.plotly-net-verso')]; return charts.length === {count} && charts.every(c => c.dataset.plotlyState === 'ready' && c.querySelector('svg.main-svg')); }}" + + let private runCell (page: IPage) (index: int) = task { + // Cell completion and notebook-wide readiness are separate Blazor renders. A disabled + // button silently ignores click(), so wait for readiness before issuing exactly one run. + let startExecution = + """() => { + const cell = document.querySelectorAll('.verso-cell')[/*INDEX*/]; + const button = cell?.querySelector('button.verso-cell-btn--run'); + if (!button || button.disabled) return false; + const previous = cell.querySelector('.verso-cell-status-count')?.textContent || ''; + const state = { started: false, done: false }; + window.__versoTestExecution = state; + const observe = () => { + const running = cell.classList.contains('verso-cell--executing'); + const runButton = cell.querySelector('button.verso-cell-btn--run'); + state.started ||= running; + state.done = !running && !!runButton && !runButton.disabled && + (state.started || (cell.querySelector('.verso-cell-status-count')?.textContent || '') !== previous); + if (state.done) observer.disconnect(); + }; + const observer = new MutationObserver(observe); + observer.observe(cell, { attributes: true, childList: true, subtree: true, characterData: true }); + button.click(); + return true; + }""".Replace("/*INDEX*/", string index) + // Counts can equal the saved CLI counts in a fresh browser kernel. Observe the execution + // lifecycle as well as the counter, including fast cells that finish between polling ticks. + // wait has a 90-second bound; it polls readiness but never repeats a started execution. + do! wait page startExecution + let watch = Stopwatch.StartNew() + let mutable complete = false + while not complete && watch.Elapsed < TimeSpan.FromSeconds(120.) do + // This harness creates the only package offered for approval. Never approve an unrelated dialog. + do! page.EvaluateFunctionAsync("() => { " + approveFixture + " }") + let! doneNow = page.EvaluateFunctionAsync("() => window.__versoTestExecution.done") + complete <- doneNow + if not complete then do! Task.Delay(200) + require complete $"Cell {index} did not finish in the browser." + let! errors = page.EvaluateFunctionAsync("i => [...document.querySelectorAll('.verso-cell')[i].querySelectorAll('.verso-output--error')].map(e=>e.textContent).join(String.fromCharCode(10))", [| box index |]) + require (String.IsNullOrWhiteSpace errors) $"Cell {index} failed in the real browser host: {errors}" + } + + let run (environment: TestEnvironment) cli onlineNotebook offlineNotebook = task { + let browserCache = + match Environment.GetEnvironmentVariable("PUPPETEER_CACHE_DIR") with + | null | "" -> Path.Combine(environment.Repository, ".cache", "verso-browser") + | value -> Path.GetFullPath(value) + let fetcher = new BrowserFetcher(BrowserFetcherOptions(Path = browserCache)) + let! installed = fetcher.DownloadAsync().WaitAsync(TimeSpan.FromMinutes(4.)) + let args = + if Environment.GetEnvironmentVariable("CI") = "true" && OperatingSystem.IsLinux() then [| "--no-sandbox" |] + else Array.empty + use! browser = Puppeteer.LaunchAsync(LaunchOptions(Headless = true, ExecutablePath = installed.GetExecutablePath(), Args = args, ProtocolTimeout = 30000)) + let plotlyFile = Directory.GetFiles(Path.Combine(environment.Repository, "src", "Plotly.NET"), "plotly-*.min.js") |> Array.exactlyOne + let bundle = File.ReadAllText(plotlyFile) + + let exercise (name: string) (notebook: string) (count: int) (offline: bool) = task { + // Separate hosts also isolate the host's process-wide background-exception observer + // from work finishing on a disposed circuit from the preceding fixture. + let port = freePort() + let baseUrl = $"http://127.0.0.1:{port}" + use server = new ChildProcess(cli, [ "serve"; "--no-browser"; "--no-https"; "--port"; string port; "--language"; "en" ], + environment.RunDirectory, environment.Variables, Path.Combine(environment.RunDirectory, name + "-serve.log")) + do! waitForServer server baseUrl + use! page = browser.NewPageAsync() + page.DefaultTimeout <- 90000 + page.DefaultNavigationTimeout <- 90000 + let errors = new ConcurrentQueue() + let messages = new ConcurrentQueue() + let mutable plotlyRequests = 0 + page.PageError.Add(fun e -> errors.Enqueue(e.Message)) + page.Console.Add(fun e -> + messages.Enqueue(e.Message.Type.ToString() + ": " + e.Message.Text) + if e.Message.Type = ConsoleType.Error then errors.Enqueue("Console error: " + e.Message.Text)) + do! page.SetRequestInterceptionAsync(true) + page.Request.Add(fun e -> + task { + try + let uri = Uri(e.Request.Url) + let isPlotly = uri.Host.Equals("cdn.plot.ly", StringComparison.OrdinalIgnoreCase) + if isPlotly then + System.Threading.Interlocked.Increment(&plotlyRequests) |> ignore + if offline then do! e.Request.AbortAsync() + else + // Deterministic network timing and the actual repository bundle, never a CDN-dependent pass. + do! Task.Delay(350) + do! e.Request.RespondAsync(ResponseData(Status = HttpStatusCode.OK, ContentType = "application/javascript", Body = bundle)) + elif offline && uri.Scheme <> "data" && uri.Scheme <> "blob" && not uri.IsLoopback then + // Verso lazily loads tokenizers/workers and Mermaid after opening a + // notebook. Permit only those host assets, never chart dependencies. + let isHostAsset = uri.Host = "cdn.jsdelivr.net" && + (uri.AbsolutePath.StartsWith("/npm/monaco-editor@0.45.0/min/") || + uri.AbsolutePath = "/npm/mermaid@11/dist/mermaid.min.js") + if isHostAsset then do! e.Request.ContinueAsync() + else + errors.Enqueue("Unexpected external request during Full rendering: " + e.Request.Url) + do! e.Request.AbortAsync() + else do! e.Request.ContinueAsync() + with ex -> errors.Enqueue("Request interception: " + ex.Message) + } |> ignore) + let openNotebook () = task { + let! _ = page.GoToAsync(baseUrl).WaitAsync(TimeSpan.FromSeconds(100.)) + do! wait page "() => !!window.monaco && !!window.versoMonaco" + do! page.EvaluateFunctionAsync("() => window.versoMonaco.waitForReady()") + // Opening interactively avoids Verso 1.2.3's recover-query prerender waiting for + // consent before the browser has an interactive circuit to answer that consent. + // Cached Monaco may initialize before Blazor has attached button handlers. + // Retry this harmless toggle until its input is visibly mounted. + do! wait page "() => { if(document.querySelector('input.verso-toolbar-input')) return true; if(Date.now()-(window.__versoOpenClickAt||0)>750) { const button=[...document.querySelectorAll('button.verso-toolbar-btn')].find(b=>b.textContent.trim()==='Open'); if(button) {window.__versoOpenClickAt=Date.now(); button.click();} } return false; }" + do! page.TypeAsync("input.verso-toolbar-input", notebook) + do! page.EvaluateFunctionAsync("() => [...document.querySelectorAll('button.verso-toolbar-btn')].find(b=>b.textContent.trim()==='Go').click()") + do! waitCharts page count + do! wait page "() => !document.querySelector('.verso-modal')" + } + try + do! openNotebook() + let! amdSafe = page.EvaluateFunctionAsync("() => !window.define || !window.define.amd") + require amdSafe "The supported host still exposes define.amd." + let! distinct = page.EvaluateFunctionAsync("() => {const ids=[...document.querySelectorAll('.js-plotly-plot')].map(c=>c.id); return ids.length===new Set(ids).size;}") + require distinct "Separate notebook charts share a DOM id." + if offline then require (plotlyRequests = 0) "Full rendering requested a CDN dependency." + else require (plotlyRequests > 0) "The CDN fixture did not exercise asynchronous external script loading." + + // Execute the actual packaged extension in the UI kernel, then rerun the first chart twice. + do! runCell page 0 + do! runCell page 1 + let! provenance = page.EvaluateFunctionAsync("() => document.querySelectorAll('.verso-cell')[1].innerText") + Notebook.validateProvenance environment provenance + let mutable lastChartId = "" + for _ in 1 .. 2 do + let! oldId = page.EvaluateFunctionAsync("() => document.querySelectorAll('.verso-cell')[2].querySelector('.js-plotly-plot').id") + do! runCell page 2 + do! waitCharts page count + let! newId = page.EvaluateFunctionAsync("() => document.querySelectorAll('.verso-cell')[2].querySelector('.js-plotly-plot').id") + require (newId <> oldId) "Rerunning a cell did not replace its chart." + lastChartId <- newId + if not offline then + let! traces = page.EvaluateFunctionAsync("() => document.querySelectorAll('.verso-cell')[3].querySelector('.js-plotly-plot').data.length") + require (traces = 2) "MultiChart lost a trace in the host renderer." + let beforeSave = File.GetLastWriteTimeUtc(notebook) + do! page.EvaluateFunctionAsync("() => { const button = [...document.querySelectorAll('button.verso-toolbar-btn')].find(b => b.textContent.trim().startsWith('Save')); if (!button) throw new Error('Save button missing'); button.click(); }") + let saving = Stopwatch.StartNew() + let mutable persisted = false + while not persisted && saving.Elapsed < TimeSpan.FromSeconds(15.) do + if File.GetLastWriteTimeUtc(notebook) <> beforeSave then + try + // A Full output is several megabytes. Timestamp changes at the start + // of the asynchronous write, before its file lock is released. + let saved = JsonNode.Parse(File.ReadAllText(notebook)) + let savedCell = saved["cells"].AsArray()[2] + let savedOutputs = savedCell["outputs"] + persisted <- savedOutputs.AsArray() |> Seq.exists (fun o -> o["content"].GetValue().Contains(lastChartId)) + with + | :? IOException -> () + | :? System.Text.Json.JsonException -> () + if not persisted then do! Task.Delay(100) + require persisted "Save did not persist the rerun browser chart within 15 seconds." + do! page.EvaluateFunctionAsync("() => window.versoRecovery.clearState()") + do! openNotebook() + let! reopenedId = page.EvaluateFunctionAsync("() => document.querySelectorAll('.verso-cell')[2].querySelector('.js-plotly-plot').id") + require (reopenedId = lastChartId) "Reopening did not restore the chart saved after rerun." + let! values = page.EvaluateFunctionAsync("() => document.querySelectorAll('.verso-cell')[2].querySelector('.js-plotly-plot').data[0].y") + require (values = [| 4; 6; 5 |]) "Reopened chart data changed." + if offline then require (plotlyRequests = 0) "Reopened Full output requested a CDN dependency." + require errors.IsEmpty ("Browser errors: " + String.Join("\n", errors)) + let! chartElement = page.QuerySelectorAsync(".js-plotly-plot") + do! chartElement.ScreenshotAsync(Path.Combine(environment.RunDirectory, name + "-chart.png")).WaitAsync(TimeSpan.FromSeconds(10.)) + do! page.ScreenshotAsync(Path.Combine(environment.RunDirectory, name + ".png"), ScreenshotOptions(FullPage = true)).WaitAsync(TimeSpan.FromSeconds(10.)) + printfn "PASS real Verso browser: %s, rerun, save, reopen" name + File.WriteAllLines(Path.Combine(environment.RunDirectory, name + "-console.log"), messages) + File.WriteAllLines(Path.Combine(environment.RunDirectory, name + "-errors.log"), errors) + with ex -> + try + let! html = page.GetContentAsync().WaitAsync(TimeSpan.FromSeconds(10.)) + File.WriteAllText(Path.Combine(environment.RunDirectory, name + "-failure.html"), html) + do! page.ScreenshotAsync(Path.Combine(environment.RunDirectory, name + "-failure.png"), ScreenshotOptions(FullPage = true)).WaitAsync(TimeSpan.FromSeconds(10.)) + with _ -> () + File.WriteAllLines(Path.Combine(environment.RunDirectory, name + "-console.log"), messages) + File.WriteAllLines(Path.Combine(environment.RunDirectory, name + "-errors.log"), errors) + return raise ex + } + do! exercise "online" onlineNotebook 2 false + // A fresh document has no window.Plotly. Block Plotly requests from first paint while allowing + // the host's own Monaco CDN dependency, so this measures the chart's offline contract. + do! exercise "offline" offlineNotebook 1 true + } diff --git a/tests/VersoTests/Integration/Infrastructure.fs b/tests/VersoTests/Integration/Infrastructure.fs new file mode 100644 index 00000000..aba58219 --- /dev/null +++ b/tests/VersoTests/Integration/Infrastructure.fs @@ -0,0 +1,107 @@ +namespace Plotly.NET.Verso.IntegrationTests + +open System +open System.Collections.Generic +open System.Diagnostics +open System.IO +open System.Net +open System.Net.Http +open System.Net.Sockets +open System.Security.Cryptography +open System.Text +open System.Threading.Tasks + +[] +type PackageManifest = + { coreVersion: string + versoVersion: string + configuration: string + coreAssembly: string + versoAssembly: string + coreSha256: string + versoSha256: string } + +type TestEnvironment = + { Repository: string + Artifacts: string + RunDirectory: string + Variables: IReadOnlyDictionary + Manifest: PackageManifest } + +/// Each child has its own log and lifetime. A failed assertion must never leave a server running. +type ChildProcess(command: string, arguments: string list, directory: string, variables: IReadOnlyDictionary, logPath: string) = + let log = new StringBuilder() + let writer = new StreamWriter(logPath, false, UTF8Encoding(false), AutoFlush = true) + let start = ProcessStartInfo(command, WorkingDirectory = directory, UseShellExecute = false, + RedirectStandardOutput = true, RedirectStandardError = true, + CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden) + let child = new Process(StartInfo = start) + do + for argument in arguments do start.ArgumentList.Add(argument) + for KeyValue(key, value) in variables do start.Environment[key] <- value + let capture (line: string) = + if not (isNull line) then + lock log (fun () -> + log.AppendLine(line) |> ignore + writer.WriteLine(line)) + child.OutputDataReceived.Add(fun e -> capture e.Data) + child.ErrorDataReceived.Add(fun e -> capture e.Data) + if not (child.Start()) then failwith $"Could not start {command}." + child.BeginOutputReadLine() + child.BeginErrorReadLine() + + member _.HasExited = child.HasExited + member _.Log = lock log (fun () -> log.ToString()) + member this.WaitAsync(timeout: TimeSpan) = task { + try + do! child.WaitForExitAsync().WaitAsync(timeout) + // Drain the asynchronous stdout/stderr callbacks before reading the log. + child.WaitForExit() + if child.ExitCode <> 0 then + failwith $"{command} exited with {child.ExitCode}. See {logPath}\n{this.Log}" + with :? TimeoutException -> + if not child.HasExited then child.Kill(true) + failwith $"{command} exceeded {timeout}. See {logPath}\n{this.Log}" + } + interface IDisposable with + member _.Dispose() = + if not child.HasExited then + child.Kill(true) + child.WaitForExit(15000) |> ignore + child.Dispose() + writer.Dispose() + +module Infrastructure = + let require condition message = if not condition then failwith message + + let sha256 (bytes: byte[]) = SHA256.HashData(bytes) |> Convert.ToHexString + + let runProcess (environment: TestEnvironment) name command arguments timeout = task { + use child = new ChildProcess(command, arguments, environment.RunDirectory, environment.Variables, + Path.Combine(environment.RunDirectory, name + ".log")) + do! child.WaitAsync(timeout) + return child.Log + } + + let freePort () = + let listener = new TcpListener(IPAddress.Loopback, 0) + listener.Start() + let port = (listener.LocalEndpoint :?> IPEndPoint).Port + listener.Stop() + port + + let waitForServer (server: ChildProcess) (url: string) = task { + use client = new HttpClient(Timeout = TimeSpan.FromSeconds(3.)) + let watch = Stopwatch.StartNew() + let mutable ready = false + while not ready && watch.Elapsed < TimeSpan.FromSeconds(90.) do + require (not server.HasExited) $"Verso exited before HTTP startup.\n{server.Log}" + try + use! response = client.GetAsync(url) + ready <- response.IsSuccessStatusCode + with + | :? HttpRequestException -> () + | :? TaskCanceledException -> () + if not ready then do! Task.Delay(250) + require ready $"Verso did not answer within 90 seconds.\n{server.Log}" + } diff --git a/tests/VersoTests/Integration/Notebook.fs b/tests/VersoTests/Integration/Notebook.fs new file mode 100644 index 00000000..47a32b15 --- /dev/null +++ b/tests/VersoTests/Integration/Notebook.fs @@ -0,0 +1,110 @@ +namespace Plotly.NET.Verso.IntegrationTests + +open System +open System.IO +open System.IO.Compression +open System.Text.Json +open System.Text.Json.Nodes +open System.Xml.Linq +open Infrastructure + +module Notebook = + let private node (value: string) : JsonNode = JsonValue.Create(value) + + let cell source = + let result = new JsonObject() + result["id"] <- node (Guid.NewGuid().ToString()) + result["type"] <- node "code" + result["language"] <- node "fsharp" + result["source"] <- node source + result["outputs"] <- new JsonArray() + result + + let document (cells: JsonObject list) = + let metadata = new JsonObject() + metadata["title"] <- node "Plotly.NET.Verso packaged integration" + metadata["defaultKernel"] <- node "fsharp" + let result = new JsonObject() + result["verso"] <- node "1.0" + result["metadata"] <- metadata + result["cells"] <- new JsonArray(cells |> List.map (fun c -> c :> JsonNode) |> List.toArray) + result + + let write path (doc: JsonNode) = + File.WriteAllText(path, doc.ToJsonString(JsonSerializerOptions(WriteIndented = true))) + + let validatePackages (environment: TestEnvironment) = + let manifest = environment.Manifest + let feed = Path.Combine(environment.RunDirectory, "feed") + Directory.CreateDirectory(feed) |> ignore + for name, version, tfm, expected in + [ "Plotly.NET", manifest.coreVersion, "netstandard2.0", manifest.coreSha256 + "Plotly.NET.Verso", manifest.versoVersion, "net8.0", manifest.versoSha256 ] do + let filename = $"{name}.{version}.nupkg" + let destination = Path.Combine(feed, filename) + File.Copy(Path.Combine(environment.Artifacts, "feed", filename), destination) + use package = ZipFile.OpenRead(destination) + let entry = package.GetEntry($"lib/{tfm}/{name}.dll") + require (not (isNull entry)) $"{filename} lacks its expected lib/{tfm} assembly." + use content = entry.Open() + use buffer = new MemoryStream() + content.CopyTo(buffer) + require (sha256(buffer.ToArray()) = expected) $"{filename} does not contain the locally built assembly." + let nuspec = package.Entries |> Seq.find (fun e -> e.FullName.EndsWith(".nuspec", StringComparison.Ordinal)) + use nuspecStream = nuspec.Open() + let metadata = XDocument.Load(nuspecStream).Root.Elements() |> Seq.find (fun e -> e.Name.LocalName = "metadata") + let property key = metadata.Elements() |> Seq.find (fun e -> e.Name.LocalName = key) |> fun e -> e.Value + require (property "id" = name && property "version" = version) $"Unexpected package identity in {filename}." + if name = "Plotly.NET.Verso" then + let deps = metadata.Descendants() |> Seq.filter (fun e -> e.Name.LocalName = "dependency") |> Seq.toArray + require (deps |> Array.exists (fun e -> e.Attribute(XName.Get "id").Value = "Verso.Abstractions" && e.Attribute(XName.Get "version").Value.Contains("1.2.3"))) "Verso.Abstractions 1.2.3 is missing from the package." + require (deps |> Array.exists (fun e -> e.Attribute(XName.Get "id").Value = "Plotly.NET" && e.Attribute(XName.Get "version").Value = "[6.0.0, 7.0.0)")) "Plotly.NET package dependency must advertise the supported [6.0.0, 7.0.0) range." + require (deps |> Array.forall (fun e -> e.Attribute(XName.Get "id").Value <> "Verso.Testing")) "Test dependencies leaked into the shipping package." + + let createFixtures (environment: TestEnvironment) = + let setup = $"#!extension Plotly.NET.Verso {environment.Manifest.versoVersion}\nopen Plotly.NET" + let provenance = """open System +open System.IO +open System.Security.Cryptography +let hashFile path = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) +for name in [ "Plotly.NET"; "Plotly.NET.Verso" ] do + let assemblies = AppDomain.CurrentDomain.GetAssemblies() |> Array.filter (fun a -> a.GetName().Name = name) + if assemblies.Length = 0 then failwith ("Missing assembly: " + name) + for assembly in assemblies do + printfn "ASSEMBLY|%s|%s|%s" name (hashFile assembly.Location) assembly.Location +""" + let sources = + [ "Chart.Line([1;2;3], [4;6;5], UseDefaults = false)" + "Chart.combine [ Chart.Line([1;2;3], [1;2;3], UseDefaults = false); Chart.Point([1;2;3], [3;1;2], UseDefaults = false) ]" ] + let online = Path.Combine(environment.RunDirectory, "online.verso") + document ([ cell setup; cell provenance ] @ (sources |> List.map cell)) |> write online + let offline = Path.Combine(environment.RunDirectory, "offline.verso") + let full = "Chart.Line([1;2;3], [4;6;5], UseDefaults = false)\n|> Chart.withDisplayOptions (DisplayOptions.init(PlotlyJSReference = Full))" + document [ cell setup; cell provenance; cell full ] |> write offline + online, offline + + let validateProvenance (environment: TestEnvironment) (provenance: string) = + for name, expected in [ "Plotly.NET", environment.Manifest.coreSha256; "Plotly.NET.Verso", environment.Manifest.versoSha256 ] do + let lines = provenance.Split('\n') |> Array.map (fun l -> l.TrimEnd('\r')) |> Array.filter (fun l -> l.StartsWith("ASSEMBLY|" + name + "|", StringComparison.Ordinal)) + require (lines.Length > 0) $"The real kernel did not report {name} provenance." + for line in lines do + let parts = line.Split('|', 4) + require (parts[2] = expected) $"Kernel loaded a stale or public {name} assembly: {line}" + require (Path.GetFullPath(parts[3]).StartsWith(environment.RunDirectory + string Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) $"Kernel loaded {name} outside the isolated run: {line}" + + let validateResult (environment: TestEnvironment) resultPath expectedCharts = + let root = JsonNode.Parse(File.ReadAllText(resultPath)) + let summary = root["summary"] + require (summary["failed"].GetValue() = 0) $"Verso reported failed cells in {resultPath}." + require (summary["succeeded"].GetValue() = summary["total"].GetValue()) $"Verso did not execute every fixture cell in {resultPath}." + let outputs = + root["cells"].AsArray() + |> Seq.collect (fun c -> c["outputs"].AsArray()) + |> Seq.toArray + for output in outputs do + let error = output["isError"] + require (isNull error || not (error.GetValue())) $"Verso emitted an error in {resultPath}." + let contents = outputs |> Array.map (fun o -> o["content"].GetValue()) + let charts = contents |> Array.filter (fun s -> s.Contains("plotly-net-verso")) + require (charts.Length = expectedCharts) $"Expected {expectedCharts} formatted charts, found {charts.Length} in {resultPath}." + contents |> String.concat "\n" |> validateProvenance environment diff --git a/tests/VersoTests/Integration/Program.fs b/tests/VersoTests/Integration/Program.fs new file mode 100644 index 00000000..aa0253ef --- /dev/null +++ b/tests/VersoTests/Integration/Program.fs @@ -0,0 +1,71 @@ +namespace Plotly.NET.Verso.IntegrationTests + +open System +open System.Collections.Generic +open System.IO +open System.Text.Json +open System.Threading.Tasks +open System.Xml.Linq +open Infrastructure + +module Program = + [] + let HostVersion = "1.2.3" + + let private prepare repository artifacts = + let run = Path.Combine(artifacts, "runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "-" + Guid.NewGuid().ToString("N").Substring(0, 8)) + Directory.CreateDirectory(run) |> ignore + let variables = new Dictionary() + for key, folder in + [ "NUGET_PACKAGES", "packages"; "NUGET_HTTP_CACHE_PATH", "http-cache"; "NUGET_PLUGINS_CACHE_PATH", "plugins-cache" + "DOTNET_CLI_HOME", "dotnet-home"; "TEMP", "temp"; "TMP", "temp"; "TMPDIR", "temp" + "APPDATA", "appdata"; "LOCALAPPDATA", "localappdata"; "XDG_CONFIG_HOME", "config"; "XDG_CACHE_HOME", "cache" ] do + let directory = Path.Combine(run, folder) + Directory.CreateDirectory(directory) |> ignore + variables[key] <- directory + variables["DOTNET_NOLOGO"] <- "1" + variables["DOTNET_CLI_TELEMETRY_OPTOUT"] <- "1" + variables["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] <- "1" + let manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(artifacts, "packages.json"))) + let environment = { Repository = repository; Artifacts = artifacts; RunDirectory = run; Variables = variables; Manifest = manifest } + Notebook.validatePackages environment + let config = + XDocument(XElement(XName.Get "configuration", + XElement(XName.Get "packageSources", + XElement(XName.Get "clear"), + XElement(XName.Get "add", XAttribute(XName.Get "key", "local"), XAttribute(XName.Get "value", Path.Combine(run, "feed"))), + XElement(XName.Get "add", XAttribute(XName.Get "key", "nuget.org"), XAttribute(XName.Get "value", "https://api.nuget.org/v3/index.json"))))) + config.Save(Path.Combine(run, "NuGet.Config")) + File.Copy(Path.Combine(artifacts, "packages.json"), Path.Combine(run, "packages.json")) + environment + + let private execute repository artifacts = task { + let environment = prepare repository artifacts + printfn "Verso %s packaged integration; diagnostics: %s" HostVersion environment.RunDirectory + let tools = Path.Combine(environment.RunDirectory, "tools") + let! _ = runProcess environment "install-cli" "dotnet" + [ "tool"; "install"; "Verso.Cli"; "--version"; HostVersion; "--tool-path"; tools + "--configfile"; Path.Combine(environment.RunDirectory, "NuGet.Config") ] (TimeSpan.FromMinutes(5.)) + let cli = Path.Combine(tools, if OperatingSystem.IsWindows() then "verso.exe" else "verso") + let! version = runProcess environment "cli-version" cli [ "--version" ] (TimeSpan.FromSeconds(30.)) + require (version.Contains(HostVersion)) $"Unexpected CLI version: {version}" + let online, offline = Notebook.createFixtures environment + for name, notebook, charts in [ "online", online, 2; "offline", offline, 1 ] do + let output = Path.Combine(environment.RunDirectory, name + "-result.json") + let! _ = runProcess environment (name + "-run") cli + [ "run"; notebook; "--output"; "json"; "--output-file"; output; "--save"; "--fail-fast"; "--timeout"; "300" ] + (TimeSpan.FromMinutes(6.)) + Notebook.validateResult environment output charts + printfn "PASS packaged CLI: %s (all cells, formatter discovery, local assembly hashes, saved output)" name + do! BrowserTests.run environment cli online offline + } + + [] + let main arguments = + try + require (arguments.Length = 2) "Usage: VersoIntegrationTests . Run through the FAKE RunVersoIntegrationTests target." + execute (Path.GetFullPath(arguments[0])) (Path.GetFullPath(arguments[1])) |> fun work -> work.GetAwaiter().GetResult() + 0 + with ex -> + eprintfn "%O" ex + 1 diff --git a/tests/VersoTests/Integration/VersoIntegrationTests.fsproj b/tests/VersoTests/Integration/VersoIntegrationTests.fsproj new file mode 100644 index 00000000..89d3d1ce --- /dev/null +++ b/tests/VersoTests/Integration/VersoIntegrationTests.fsproj @@ -0,0 +1,17 @@ + + + net10.0 + Exe + false + false + + + + + + + + + + + diff --git a/tests/VersoTests/Repack.ps1 b/tests/VersoTests/Repack.ps1 new file mode 100644 index 00000000..83627a30 --- /dev/null +++ b/tests/VersoTests/Repack.ps1 @@ -0,0 +1,13 @@ +# Prepare the local feed and a portable smoke notebook through the repository build. +# No package caches are deleted. Run RunVersoIntegrationTests for isolated host testing. +$ErrorActionPreference = 'Stop' +$repository = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +Push-Location -LiteralPath $repository +try { + dotnet run --project ./build/build.fsproj -- PackVersoIntegrationPackages + if ($LASTEXITCODE -ne 0) { throw "Package preparation failed with exit code $LASTEXITCODE." } + Write-Host "Local smoke notebook: $(Join-Path $PSScriptRoot 'artifacts/test.verso')" +} +finally { + Pop-Location +} diff --git a/tests/VersoTests/test.verso b/tests/VersoTests/test.verso new file mode 100644 index 00000000..44ee59ed --- /dev/null +++ b/tests/VersoTests/test.verso @@ -0,0 +1,44 @@ +{ + "verso": "1.0", + "metadata": { + "title": "Plotly.NET.Verso smoke test", + "defaultKernel": "fsharp", + "activeLayout": "notebook" + }, + "cells": [ + { + "id": "a43be562-b252-4bd2-a8d3-217b8cfc9080", + "type": "markdown", + "source": "# Plotly.NET.Verso smoke test\nRun `PackVersoIntegrationPackages` and open the generated `artifacts/test.verso` to test local packages before publication. This portable notebook targets package 1.0.0. Run all cells, rerun a chart, save, close, and reopen. The final chart embeds plotly.js and works offline after the host has initialized.", + "outputs": [] + }, + { + "id": "f4c2c0b5-336c-475c-8c35-8325e2a351fa", + "type": "code", + "language": "fsharp", + "source": "#!extension Plotly.NET.Verso 1.0.0\nopen Plotly.NET", + "outputs": [] + }, + { + "id": "cae43c1b-3c82-42ac-b27f-9ea30e196952", + "type": "code", + "language": "fsharp", + "source": "Chart.Line([1;2;3], [4;6;5], UseDefaults = false)", + "outputs": [] + }, + { + "id": "0d39abb6-7857-446c-9d02-b561a28ea12d", + "type": "code", + "language": "fsharp", + "source": "Chart.combine [ Chart.Line([1;2;3], [1;2;3], UseDefaults = false); Chart.Point([1;2;3], [3;1;2], UseDefaults = false) ]", + "outputs": [] + }, + { + "id": "ce58441a-667c-4ba8-a786-480fd4cf5854", + "type": "code", + "language": "fsharp", + "source": "Chart.Line([1;2;3], [4;6;5], UseDefaults = false)\n|> Chart.withDisplayOptions (DisplayOptions.init(PlotlyJSReference = Full))", + "outputs": [] + } + ] +}