From 1f9cb1cd0577890cf78f2791c68668bfa621b292 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 11:15:39 +0200 Subject: [PATCH 1/8] FEAT+TEST: Add and fix tests running against real snippets. --- JavaScriptResource.mustache | 25 + snippets/.gitignore | 4 + .../.gitignore | 432 ++++++++++++++++++ .../FiftyOne.JavascriptTemplateTests.csproj | 18 + .../SnippetTests.cs | 293 ++++++++++++ 5 files changed, 772 insertions(+) create mode 100644 snippets/.gitignore create mode 100644 tests/FiftyOne.JavascriptTemplateTests/.gitignore create mode 100644 tests/FiftyOne.JavascriptTemplateTests/FiftyOne.JavascriptTemplateTests.csproj create mode 100644 tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs diff --git a/JavaScriptResource.mustache b/JavaScriptResource.mustache index 9e14a41..666c3a3 100644 --- a/JavaScriptResource.mustache +++ b/JavaScriptResource.mustache @@ -763,6 +763,13 @@ fiftyoneDegreesManager = function() { if (callbackCounter === 0) { {{#_updateEnabled}} processRequest(resolve, reject); +{{/_updateEnabled}} +{{^_updateEnabled}} + failed = false; + completed = true; + fireChangeFuncs(json); + resolve(json); + roundEnded(); {{/_updateEnabled}} } else if (callbackCounter < 0){ reject('Too many callbacks.'); @@ -794,9 +801,11 @@ fiftyoneDegreesManager = function() { if (jsProperties !== undefined && jsProperties.length > 0) { let valueSetPrefix = new RegExp('document\\.cookie\\s*=\\s*(("([A-Za-z0-9_"\\s\\+]+)\\s*=\\s*"\\s*\\+\\s*([^\\s};]+))|(`([A-Za-z0-9_]+)\\s*=\\s*\\$\\{([^}]+)\\}`))', 'g'); + let valueGetPrefix = new RegExp('=\\s*document\\.cookie', 'g'); let session51DataPrefix = sessionKey + "_data_"; {{^_enableCookies}} let sessionSetPatch = 'window.sessionStorage["' + session51DataPrefix + '$3$6"]=$4$7'; + let sessionGetPatch = '= (()=>Array.from({length:sessionStorage.length},(_,i)=>sessionStorage.key(i)).filter(k=>k&&k.startsWith("' + session51DataPrefix + '")).map(k=>encodeURIComponent(k)+"="+encodeURIComponent(sessionStorage.getItem(k))).join("; "))()'; {{/_enableCookies}} // Store an empty result for each value a snippet sets that has @@ -880,7 +889,23 @@ fiftyoneDegreesManager = function() { storeEmptyValues(body); {{^_enableCookies}} + {{#_diagnoseUnconvertedCookies}} + var rawBody = body + {{/_diagnoseUnconvertedCookies}} body = body.replaceAll(valueSetPrefix, sessionSetPatch); + body = body.replaceAll(valueGetPrefix, sessionGetPatch); + {{#_diagnoseUnconvertedCookies}} + var cookiePos = body.indexOf('document.cookie'); + if (cookiePos !== -1) { + console.log("'document.cookie' found at: " + cookiePos) + catchError(new Error('Snippet ' + name + ' contains unconverted document.cookie after session storage patch')); + } else { + console.log("No 'document.cookie' found.") + } + console.log(body) + console.log("--- RAW BODY ---") + console.log(rawBody) + {{/_diagnoseUnconvertedCookies}} {{/_enableCookies}} if (body.indexOf(searchString) !== -1){ diff --git a/snippets/.gitignore b/snippets/.gitignore new file mode 100644 index 0000000..dd79758 --- /dev/null +++ b/snippets/.gitignore @@ -0,0 +1,4 @@ +# This directory is populated by the js-snippet-export tool during CI +# Do not commit snippet files to version control +* +!.gitignore diff --git a/tests/FiftyOne.JavascriptTemplateTests/.gitignore b/tests/FiftyOne.JavascriptTemplateTests/.gitignore new file mode 100644 index 0000000..828a634 --- /dev/null +++ b/tests/FiftyOne.JavascriptTemplateTests/.gitignore @@ -0,0 +1,432 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +.artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# Official VS Code C# Dev Kit Extension exclusion +#*.lscache diff --git a/tests/FiftyOne.JavascriptTemplateTests/FiftyOne.JavascriptTemplateTests.csproj b/tests/FiftyOne.JavascriptTemplateTests/FiftyOne.JavascriptTemplateTests.csproj new file mode 100644 index 0000000..383ecd5 --- /dev/null +++ b/tests/FiftyOne.JavascriptTemplateTests/FiftyOne.JavascriptTemplateTests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + diff --git a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs new file mode 100644 index 0000000..277c621 --- /dev/null +++ b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs @@ -0,0 +1,293 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using Stubble.Core.Builders; +using System.Collections; +using System.Collections.Generic; +using System.Net; + +namespace FiftyOne.JavascriptTemplateTests; + +[TestClass] +public class SnippetTests +{ + private static ChromeDriver _driver = null!; + private static string _baseDir = null!; + private static string _template = null!; + + [ClassInitialize] + public static void ClassInit(TestContext context) + { + _baseDir = FindBaseDirectory(); + _template = File.ReadAllText(Path.Combine(_baseDir, "JavaScriptResource.mustache")); + _driver = CreateDriver(); + } + + [ClassCleanup] + public static void ClassCleanup() + { + _driver?.Quit(); + } + + [TestMethod] + [DynamicData(nameof(GetSnippetTestCases), DynamicDataSourceType.Method)] + public void Snippet_ExecutesWithoutError(string propertyName, string snippet, string snippetFile) + { + var devicePropertyName = propertyName.Substring(propertyName.IndexOf('.') + 1); + var renderedJs = RenderSnippetJs(propertyName, snippet); + var html = BuildTestHtml(propertyName, renderedJs); + + // Save HTML beside the .js file + var htmlFile = snippetFile.Replace(".js", ".html"); + File.WriteAllText(htmlFile, html); + Console.WriteLine($"[{propertyName}] HTML saved to: {htmlFile}"); + + RunTestInBrowser(html, propertyName); + } + + private string RenderSnippetJs(string propertyName, string snippet) + { + var devicePropertyName = propertyName.Substring(propertyName.IndexOf('.') + 1); + var fullObject = new Dictionary + { + ["device"] = new Dictionary + { + [devicePropertyName] = snippet + }, + ["javascriptProperties"] = new List { propertyName } + }; + var testData = new Dictionary + { + ["_jsonObject"] = Newtonsoft.Json.JsonConvert.SerializeObject(fullObject), + ["_parameters"] = "{}", + ["_sessionId"] = "test-session-123", + ["_objName"] = "fod", + ["_sequence"] = "0", + ["_enableCookies"] = false, + ["_updateEnabled"] = false, + // Test-only: fail the snippet if a document.cookie assignment + // survives the session-storage patch (i.e. wasn't converted). + ["_diagnoseUnconvertedCookies"] = true + }; + return new StubbleBuilder().Build().Render(_template, testData); + } + + private string BuildTestHtml(string propertyName, string renderedJs) + { + return $@" + + + {propertyName} + + + +

Snippet Test: {propertyName}

+
Running...
+
+

Console Log:

+
+ + + + +"; + } + + private void RunTestInBrowser(string html, string propertyName) + { + using var server = new SimpleHttpServer(html); + _driver.Navigate().GoToUrl($"http://localhost:{server.Port}/"); + + var statusEl = _driver.FindElement(By.Id("status")); + var deadline = DateTime.Now.AddSeconds(2); + while (DateTime.Now < deadline && statusEl.GetAttribute("class") == "pending") + { + System.Threading.Thread.Sleep(50); + statusEl = _driver.FindElement(By.Id("status")); + } + + var statusClass = statusEl.GetAttribute("class"); + var statusText = statusEl.Text; + string errors = ""; + string logs = ""; + try { errors = _driver.FindElement(By.Id("errors")).Text; } catch { } + try { logs = _driver.FindElement(By.Id("logs")).Text; } catch { } + + Console.WriteLine($"[{propertyName}] Status: {statusText}"); + if (!string.IsNullOrEmpty(logs)) Console.WriteLine($"[{propertyName}] Logs:\n{logs}"); + + var browserLogs = _driver.Manage().Logs.GetLog(LogType.Browser); + var jsErrors = browserLogs.Where(l => l.Level == LogLevel.Severe) + .Select(l => l.Message) + .Where(m => !m.Contains("net::ERR")) + .ToList(); + + if (statusClass == "fail") + Assert.Fail($"{propertyName} failed.\nPage: {errors}\nBrowser: {string.Join("\n", jsErrors)}"); + if (jsErrors.Any()) + Assert.Fail($"{propertyName} browser errors:\n" + string.Join("\n", jsErrors)); + } + + public static IEnumerable GetSnippetTestCases() + { + var baseDir = _baseDir ?? FindBaseDirectory(); + var snippetsDir = Path.Combine(baseDir, "snippets"); + + // TEMPORARY: Only first 3 snippets for debugging + foreach (var file in Directory.GetFiles(snippetsDir, "*.js")) + { + var name = Path.GetFileNameWithoutExtension(file); + var propertyName = ConvertToPropertyName(name); + var snippet = File.ReadAllText(file); + yield return new object[] { propertyName, snippet, file }; + } + } + + private static string FindBaseDirectory() + { + var dir = Directory.GetCurrentDirectory(); + while (dir != null) + { + if (File.Exists(Path.Combine(dir, "JavaScriptResource.mustache"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + throw new InvalidOperationException("Could not find repository root"); + } + + private static ChromeDriver CreateDriver() + { + var options = new ChromeOptions(); + options.AddArgument("--headless"); + options.AddArgument("--no-sandbox"); + options.AddArgument("--disable-dev-shm-usage"); + options.AddArgument("--disable-gpu"); + + var driver = new ChromeDriver(options); + driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(30); + return driver; + } + + private static string ConvertToPropertyName(string fileName) + { + var name = fileName; + if (name.Contains('_')) + name = name.Substring(0, name.LastIndexOf('_')); + return $"device.{name}"; + } +} + +class SimpleHttpServer : IDisposable +{ + private readonly HttpListener _listener; + private readonly Thread _thread; + private readonly string _content; + private bool _running; + + public int Port { get; } + + public SimpleHttpServer(string content) + { + _content = content; + _listener = new HttpListener(); + + for (int port = 8765; port < 9000; port++) + { + try + { + _listener.Prefixes.Add($"http://localhost:{port}/"); + _listener.Start(); + Port = port; + break; + } + catch { } + } + + _running = true; + _thread = new Thread(() => + { + while (_running) + { + try + { + var ctx = _listener.GetContext(); + var bytes = System.Text.Encoding.UTF8.GetBytes(_content); + ctx.Response.ContentType = "text/html"; + ctx.Response.ContentLength64 = bytes.Length; + ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); + ctx.Response.OutputStream.Close(); + } + catch { } + } + }) { IsBackground = true }; + _thread.Start(); + } + + public void Dispose() + { + _running = false; + try { _listener.Stop(); } catch { } + try { _listener.Close(); } catch { } + } +} From 95f87b2096338e24372039e89cc51a45729b1ca2 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 15:58:42 +0200 Subject: [PATCH 2/8] CI: Migrate to `pull-requests.yml`. --- .../workflows/monthly-copyright-update.yml | 18 +++ .github/workflows/pull-requests.yml | 23 ++++ .github/workflows/template-tests.yml | 37 ------ ci/README.md | 30 +++++ ci/build-project.ps1 | 53 +++++++++ ci/fetch-assets.ps1 | 33 ++++++ ci/options.json | 20 ++++ ci/run-integration-tests.ps1 | 110 ++++++++++++++++++ ci/run-unit-tests.ps1 | 49 ++++++++ ci/setup-environment.ps1 | 41 +++++++ .../SnippetTests.cs | 3 +- 11 files changed, 378 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/monthly-copyright-update.yml create mode 100644 .github/workflows/pull-requests.yml delete mode 100644 .github/workflows/template-tests.yml create mode 100644 ci/README.md create mode 100644 ci/build-project.ps1 create mode 100644 ci/fetch-assets.ps1 create mode 100644 ci/options.json create mode 100644 ci/run-integration-tests.ps1 create mode 100644 ci/run-unit-tests.ps1 create mode 100644 ci/setup-environment.ps1 diff --git a/.github/workflows/monthly-copyright-update.yml b/.github/workflows/monthly-copyright-update.yml new file mode 100644 index 0000000..06699b9 --- /dev/null +++ b/.github/workflows/monthly-copyright-update.yml @@ -0,0 +1,18 @@ +name: Monthly Copyright Update + +on: + workflow_dispatch: + schedule: + # * is a special character in YAML so quote this string + - cron: '0 0 1 * *' + +jobs: + # Run the common workflow that rewrites copyright headers and opens a PR. + # The resulting PR is validated and merged by the Pull Requests pipeline. + Monthly_Copyright_Update: + uses: 51Degrees/common-ci/.github/workflows/monthly-copyright-update.yml@main + with: + repo-name: ${{ github.event.repository.name }} + org-name: ${{ github.event.repository.owner.login }} + secrets: + token: ${{ secrets.ACCESS_TOKEN }} diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml new file mode 100644 index 0000000..7e77baf --- /dev/null +++ b/.github/workflows/pull-requests.yml @@ -0,0 +1,23 @@ +name: Pull Requests + +on: + pull_request: + workflow_dispatch: + inputs: + dryrun: + type: boolean + default: false + +jobs: + PullRequests: + name: Pull Requests + uses: 51Degrees/common-ci/.github/workflows/nightly-pull-requests.yml@main + with: + repo-name: ${{ github.event.repository.name }} + org-name: ${{ github.event.repository.owner.login }} + dryrun: ${{ inputs.dryrun || false }} + cache-assets: true + secrets: + token: ${{ secrets.ACCESS_TOKEN }} + DeviceDetection: ${{ secrets.DEVICE_DETECTION_KEY }} + DeviceDetectionUrl: ${{ secrets.DEVICE_DETECTION_URL }} diff --git a/.github/workflows/template-tests.yml b/.github/workflows/template-tests.yml deleted file mode 100644 index 7cb22f2..0000000 --- a/.github/workflows/template-tests.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Template tests - -# The template is not executable on its own, so these checks render it with a -# model matching the one the .NET builder passes and then drive the rendered -# script against a fake endpoint. They take seconds and they reach what the -# consumer run cannot, being the environment with no window that the cloud's -# NiL.JS builder evaluates the script in, and the failure paths a browser test -# cannot force. - -on: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - template-tests: - name: Render and drive the template - runs-on: ubuntu-latest - steps: - - name: Check out the template under review - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: npm - cache-dependency-path: tests/package-lock.json - - - name: Install the renderer - working-directory: tests - run: npm ci - - - name: Render and drive the template - working-directory: tests - run: npm test diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..d49ed89 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,30 @@ +# CI scripts + +These scripts implement the hook contract that the shared +[`common-ci`](https://github.com/51Degrees/common-ci) reusable workflows expect. +`nightly-pull-request.build-and-test.ps1` in `common-ci` clones this repository +and invokes the hooks below, in order, with the matrix entry from +[`options.json`](options.json) splatted in as parameters: + +| Hook | Purpose | +| --- | --- | +| `fetch-assets.ps1` | Downloads the Hash data file the snippet tool generates from. | +| `setup-environment.ps1` | Installs the native toolchain (CMake, g++) used to build the snippet tool. | +| `build-project.ps1` | Installs Node deps and builds the C# snippet test project. | +| `run-unit-tests.ps1` | Renders `JavaScriptResource.mustache` and drives it with `node template-tests.js`. | +| `run-integration-tests.ps1` | Clones `device-detection-cxx`, builds `js-snippet-export`, generates `snippets/`, and drives every snippet through the template in a real browser. | + +## The snippet pipeline + +The template itself is not executable, so coverage comes in two layers: + +* **Unit** (`run-unit-tests.ps1`) - fast, window-less checks of the rendered + script against a fake endpoint. No data file or browser required. +* **Integration** (`run-integration-tests.ps1`) - the producer/consumer chain. + `device-detection-cxx`'s `js-snippet-export` tool is the *producer* (the + equivalent of the examples repo that `device-detection-dotnet` clones for its + integration coverage); the C# `FiftyOne.JavascriptTemplateTests` project is + the *consumer* that renders and browser-checks each generated snippet. + +`snippets/` is a build artifact and is not committed - it is populated afresh by +the integration step on every run. diff --git a/ci/build-project.ps1 b/ci/build-project.ps1 new file mode 100644 index 0000000..e542589 --- /dev/null +++ b/ci/build-project.ps1 @@ -0,0 +1,53 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +param( + [Parameter(Mandatory)][string]$RepoName, + [string]$ProjectDir = ".", + [string]$Name = "Release_x64", + [string]$Configuration = "Release", + [string]$Arch = "x64", + [string]$BuildMethod = "dotnet" +) +$ErrorActionPreference = "Stop" + +$RepoPath = [IO.Path]::Combine($pwd, $RepoName) + +# The Node checks (unit tests) only need their dev dependency installed. +Write-Host "Installing Node dependencies for the template renderer" +Push-Location ([IO.Path]::Combine($RepoPath, "tests")) +try { + npm ci +} finally { + Pop-Location +} + +# Build the C# snippet test project. The snippets it drives are generated later +# by the integration step, so only compilation is required here. +$TestProject = [IO.Path]::Combine( + $RepoPath, "tests", "FiftyOne.JavascriptTemplateTests", + "FiftyOne.JavascriptTemplateTests.csproj") + +Write-Host "Building $TestProject" +dotnet build $TestProject -c $Configuration + +exit $LASTEXITCODE diff --git a/ci/fetch-assets.ps1 b/ci/fetch-assets.ps1 new file mode 100644 index 0000000..151b60c --- /dev/null +++ b/ci/fetch-assets.ps1 @@ -0,0 +1,33 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +param ( + [string]$DeviceDetection, + [string]$DeviceDetectionUrl +) +$ErrorActionPreference = "Stop" + +# The snippet integration test needs a Hash data file to feed the +# js-snippet-export tool from device-detection-cxx. The lite file is enough to +# exercise every JavaScript property, and it is redistributable. +./steps/fetch-assets.ps1 -DeviceDetection:$DeviceDetection -DeviceDetectionUrl:$DeviceDetectionUrl ` + -Assets "51Degrees-LiteV4.1.hash" diff --git a/ci/options.json b/ci/options.json new file mode 100644 index 0000000..d639e74 --- /dev/null +++ b/ci/options.json @@ -0,0 +1,20 @@ +[ + { + "Image": "ubuntu-latest", + "Name": "Ubuntu_x64_Release", + "Configuration": "Release", + "Arch": "x64", + "BuildMethod": "dotnet", + "Language": "dotnet", + "LanguageVersion": "8.0.x" + }, + { + "Image": "windows-latest", + "Name": "Windows_x64_Release", + "Configuration": "Release", + "Arch": "x64", + "BuildMethod": "dotnet", + "Language": "dotnet", + "LanguageVersion": "8.0.x" + } +] diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 new file mode 100644 index 0000000..40a3058 --- /dev/null +++ b/ci/run-integration-tests.ps1 @@ -0,0 +1,110 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +param( + [Parameter(Mandatory)][string]$RepoName, + [Parameter(Mandatory)][string]$OrgName, + [string]$Name = "Release_x64", + [string]$Configuration = "Release", + [string]$Arch = "x64", + [string]$BuildMethod = "dotnet", + # The device-detection-cxx repo owns the js-snippet-export tool that + # produces the snippets under test - the equivalent of the examples repo + # that device-detection-dotnet clones for its integration coverage. + [string]$SnippetToolRepo = "device-detection-cxx", + [string]$SnippetToolBranch = "main" +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +Set-StrictMode -Version 1.0 + +$RepoPath = [IO.Path]::Combine($pwd, $RepoName) + +# The snippet sweep needs a data file to generate from and Chrome to run the +# rendered scripts. On runners where neither is available (e.g. an automation +# PR without the data-file secret), skip rather than fail the merge gate - +# this mirrors the guarded pattern in device-detection-dotnet. +$dataFile = Resolve-Path -ErrorAction SilentlyContinue ` + ([IO.Path]::Combine($pwd, "assets", "51Degrees-LiteV4.1.hash")) +if (-not $dataFile) { + Write-Host "::warning::No Hash data file found under assets/ - skipping snippet integration tests." + exit 0 +} + +# --------------------------------------------------------------------------- +# 1. Obtain the js-snippet-export tool source (device-detection-cxx). +# common-ci provides steps/clone-repo.ps1 which honours the org and token. +# --------------------------------------------------------------------------- +if (-not (Test-Path $SnippetToolRepo)) { + ./steps/clone-repo.ps1 -RepoName $SnippetToolRepo -OrgName $OrgName -Branch $SnippetToolBranch +} + +# common-cxx and device-detection-data are submodules of device-detection-cxx; +# the CMake configure includes them, so they must be initialised. +git -C $SnippetToolRepo submodule update --init --recursive + +# --------------------------------------------------------------------------- +# 2. Build ONLY the js-snippet-export target. It sits outside +# if(BUILD_TESTING) in the root CMakeLists, so tests are not needed, but it +# links fiftyone-hash-cxx so the engine is built transitively. +# --------------------------------------------------------------------------- +$buildDir = [IO.Path]::Combine($pwd, $SnippetToolRepo, "build") +cmake -S $SnippetToolRepo -B $buildDir -DCMAKE_BUILD_TYPE=$Configuration -DBUILD_TESTING=OFF +cmake --build $buildDir --config $Configuration --target js-snippet-export + +# The tool pins RUNTIME_OUTPUT_DIRECTORY to /bin. Multi-config +# generators (Visual Studio) nest it under the configuration. +$exeName = if ($IsWindows) { "js-snippet-export.exe" } else { "js-snippet-export" } +$cli = @( + [IO.Path]::Combine($buildDir, "bin", $exeName), + [IO.Path]::Combine($buildDir, "bin", $Configuration, $exeName) +) | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $cli) { + throw "js-snippet-export was not found under '$buildDir/bin'." +} +Write-Host "Using snippet export tool: $cli" + +# --------------------------------------------------------------------------- +# 3. Generate the snippets into the repo's ignored snippets/ directory. +# --------------------------------------------------------------------------- +$snippetsDir = [IO.Path]::Combine($RepoPath, "snippets") +New-Item -ItemType Directory -Path $snippetsDir -Force | Out-Null +& $cli -d $dataFile.Path -o $snippetsDir + +$generated = @(Get-ChildItem -Path $snippetsDir -Filter "*.js" -ErrorAction SilentlyContinue) +Write-Host "Generated $($generated.Count) snippet(s)." +if ($generated.Count -eq 0) { + throw "The snippet export tool produced no .js files." +} + +# --------------------------------------------------------------------------- +# 4. Drive every generated snippet through the template in a real browser. +# --------------------------------------------------------------------------- +$TestProject = [IO.Path]::Combine( + $RepoPath, "tests", "FiftyOne.JavascriptTemplateTests", + "FiftyOne.JavascriptTemplateTests.csproj") + +dotnet test $TestProject -c $Configuration ` + --logger "console;verbosity=normal" ` + --logger "trx;LogFileName=snippet-integration.trx" + +exit $LASTEXITCODE diff --git a/ci/run-unit-tests.ps1 b/ci/run-unit-tests.ps1 new file mode 100644 index 0000000..f73d43c --- /dev/null +++ b/ci/run-unit-tests.ps1 @@ -0,0 +1,49 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +param( + [Parameter(Mandatory)][string]$RepoName, + [string]$ProjectDir = ".", + [string]$Name = "Release_x64", + [string]$Configuration = "Release", + [string]$Arch = "x64", + [string]$BuildMethod = "dotnet" +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +$RepoPath = [IO.Path]::Combine($pwd, $RepoName) + +# The unit tests render JavaScriptResource.mustache with a model matching the +# .NET builder and drive the rendered script against a fake endpoint in a +# window-less environment. They take seconds and need no data file or browser. +# This is the coverage that used to live in the standalone template-tests.yml +# workflow. +Write-Host "Running template renderer checks (node template-tests.js)" +Push-Location ([IO.Path]::Combine($RepoPath, "tests")) +try { + npm test +} finally { + Pop-Location +} + +exit $LASTEXITCODE diff --git a/ci/setup-environment.ps1 b/ci/setup-environment.ps1 new file mode 100644 index 0000000..5ed6817 --- /dev/null +++ b/ci/setup-environment.ps1 @@ -0,0 +1,41 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +param( + [Parameter(Mandatory)][string]$RepoName, + [string]$ProjectDir = ".", + [string]$Name = "Release_x64", + [string]$Arch = "x64", + [string]$Configuration = "Release", + [string]$BuildMethod = "dotnet", + [hashtable]$Keys +) +$ErrorActionPreference = "Stop" + +# On Linux runners the js-snippet-export tool (device-detection-cxx) is built +# from source, so the native toolchain and CMake must be present. The C# test +# project also drives Chrome through Selenium; the GitHub-hosted images ship +# Chrome, but multilib support is required by the native build. +if ($IsLinux) { + sudo apt-get update + sudo apt-get install -y gcc-multilib g++-multilib cmake ninja-build +} diff --git a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs index 277c621..37de042 100644 --- a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs +++ b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs @@ -193,8 +193,7 @@ public static IEnumerable GetSnippetTestCases() { var baseDir = _baseDir ?? FindBaseDirectory(); var snippetsDir = Path.Combine(baseDir, "snippets"); - - // TEMPORARY: Only first 3 snippets for debugging + foreach (var file in Directory.GetFiles(snippetsDir, "*.js")) { var name = Path.GetFileNameWithoutExtension(file); From 9584f1ba5c9e1e6056cf0b73a67dd363994e09f6 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 15:59:01 +0200 Subject: [PATCH 3/8] CI: Use feature branch of `dd-cxx`. --- ci/run-integration-tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index 40a3058..f31ab10 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -31,7 +31,7 @@ param( # produces the snippets under test - the equivalent of the examples repo # that device-detection-dotnet clones for its integration coverage. [string]$SnippetToolRepo = "device-detection-cxx", - [string]$SnippetToolBranch = "main" + [string]$SnippetToolBranch = "feature/js-snippet-export" ) $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true From aaee7a0d370e7108fab04dab04af1ae60d0133d3 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 16:05:31 +0200 Subject: [PATCH 4/8] CI: Rename back `pull-requests.yml` to `template-tests.yml` --- .github/workflows/{pull-requests.yml => template-tests.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{pull-requests.yml => template-tests.yml} (100%) diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/template-tests.yml similarity index 100% rename from .github/workflows/pull-requests.yml rename to .github/workflows/template-tests.yml From 469446ad914310ba3305fc69509ca4eea9c510ae Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 16:51:00 +0200 Subject: [PATCH 5/8] CI: Use TAC file for integration tests. --- ci/fetch-assets.ps1 | 9 +++++---- ci/run-integration-tests.ps1 | 13 +++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ci/fetch-assets.ps1 b/ci/fetch-assets.ps1 index 151b60c..e16ffa6 100644 --- a/ci/fetch-assets.ps1 +++ b/ci/fetch-assets.ps1 @@ -26,8 +26,9 @@ param ( ) $ErrorActionPreference = "Stop" -# The snippet integration test needs a Hash data file to feed the -# js-snippet-export tool from device-detection-cxx. The lite file is enough to -# exercise every JavaScript property, and it is redistributable. +# The snippet integration test drives every JavaScript property snippet through +# the template. The full set of snippets only exists in the enterprise TAC data +# file - the Lite file carries only a handful - so TAC is required for complete +# coverage. ./steps/fetch-assets.ps1 -DeviceDetection:$DeviceDetection -DeviceDetectionUrl:$DeviceDetectionUrl ` - -Assets "51Degrees-LiteV4.1.hash" + -Assets "TAC-HashV41.hash" diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index f31ab10..c1a3492 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -39,14 +39,15 @@ Set-StrictMode -Version 1.0 $RepoPath = [IO.Path]::Combine($pwd, $RepoName) -# The snippet sweep needs a data file to generate from and Chrome to run the -# rendered scripts. On runners where neither is available (e.g. an automation -# PR without the data-file secret), skip rather than fail the merge gate - -# this mirrors the guarded pattern in device-detection-dotnet. +# The snippet sweep needs the enterprise TAC data file (it carries the full set +# of JavaScript property snippets - Lite has only a handful) and Chrome to run +# the rendered scripts. On runners where the data file is unavailable (e.g. an +# automation PR without the data-file secret), skip rather than fail the merge +# gate - this mirrors the guarded pattern in device-detection-dotnet. $dataFile = Resolve-Path -ErrorAction SilentlyContinue ` - ([IO.Path]::Combine($pwd, "assets", "51Degrees-LiteV4.1.hash")) + ([IO.Path]::Combine($pwd, "assets", "TAC-HashV41.hash")) if (-not $dataFile) { - Write-Host "::warning::No Hash data file found under assets/ - skipping snippet integration tests." + Write-Host "::warning::No TAC Hash data file found under assets/ - skipping snippet integration tests." exit 0 } From 6c3972ab06cd5879f45c5de66008e63d31354fe7 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 22:25:20 +0200 Subject: [PATCH 6/8] TEST: Do not leak full snippet code in logs. --- .../SnippetTests.cs | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs index 37de042..9e36b3a 100644 --- a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs +++ b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs @@ -52,14 +52,15 @@ public static void ClassCleanup() } [TestMethod] - [DynamicData(nameof(GetSnippetTestCases), DynamicDataSourceType.Method)] + [DynamicData(nameof(GetSnippetTestCases), DynamicDataSourceType.Method, + DynamicDataDisplayName = nameof(GetSnippetDisplayName))] public void Snippet_ExecutesWithoutError(string propertyName, string snippet, string snippetFile) { var devicePropertyName = propertyName.Substring(propertyName.IndexOf('.') + 1); var renderedJs = RenderSnippetJs(propertyName, snippet); var html = BuildTestHtml(propertyName, renderedJs); - // Save HTML beside the .js file + // Save HTML beside the .js file for manual replication after a run. var htmlFile = snippetFile.Replace(".js", ".html"); File.WriteAllText(htmlFile, html); Console.WriteLine($"[{propertyName}] HTML saved to: {htmlFile}"); @@ -159,15 +160,29 @@ private void RunTestInBrowser(string html, string propertyName) using var server = new SimpleHttpServer(html); _driver.Navigate().GoToUrl($"http://localhost:{server.Port}/"); - var statusEl = _driver.FindElement(By.Id("status")); - var deadline = DateTime.Now.AddSeconds(2); - while (DateTime.Now < deadline && statusEl.GetAttribute("class") == "pending") + // Poll for #status to exist AND leave "pending". GoToUrl can return + // before Chrome has swapped in the served document, so grabbing the + // element straight away races the page load and throws + // NoSuchElementException. Swallowing the not-yet-there element while + // polling lets a slow page read as a real result, not a missing element. + // (WebDriverWait lives in the Selenium.Support package, which we don't + // reference, so this is a hand-rolled equivalent.) + IWebElement? statusEl = null; + var deadline = DateTime.Now.AddSeconds(10); + while (DateTime.Now < deadline) { + try + { + var el = _driver.FindElement(By.Id("status")); + if (el.GetAttribute("class") != "pending") { statusEl = el; break; } + } + catch (NoSuchElementException) { /* page not loaded yet */ } System.Threading.Thread.Sleep(50); - statusEl = _driver.FindElement(By.Id("status")); } + Assert.IsNotNull(statusEl, + $"{propertyName}: #status never left 'pending' within 10s (page did not load or snippet hung)"); - var statusClass = statusEl.GetAttribute("class"); + var statusClass = statusEl!.GetAttribute("class"); var statusText = statusEl.Text; string errors = ""; string logs = ""; @@ -189,6 +204,16 @@ private void RunTestInBrowser(string html, string propertyName) Assert.Fail($"{propertyName} browser errors:\n" + string.Join("\n", jsErrors)); } + // Builds each test's display name from the property + snippet file only. + // Without this, [DynamicData] stringifies ALL args - including the full + // snippet source - into the name, dumping every snippet body into the CI log. + public static string GetSnippetDisplayName(System.Reflection.MethodInfo methodInfo, object[] data) + { + var propertyName = (string)data[0]; + var snippetFile = Path.GetFileName((string)data[2]); + return $"{methodInfo.Name}({propertyName}, {snippetFile})"; + } + public static IEnumerable GetSnippetTestCases() { var baseDir = _baseDir ?? FindBaseDirectory(); @@ -237,6 +262,9 @@ private static string ConvertToPropertyName(string fileName) } } +// Serve over localhost HTTP, not file://. These snippets write document.cookie +// and use sessionStorage; file:// gives an opaque origin where cookies no-op and +// storage leaks across pages, changing behavior. Harden the server, don't switch. class SimpleHttpServer : IDisposable { private readonly HttpListener _listener; From d490fadb209f44d317576d3e87e5c09290ae2047 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 22:45:57 +0200 Subject: [PATCH 7/8] TEST: Use a long-lived server. --- .../SnippetTests.cs | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs index 9e36b3a..369166e 100644 --- a/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs +++ b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs @@ -31,11 +31,13 @@ namespace FiftyOne.JavascriptTemplateTests; [TestClass] +[DoNotParallelize] // one shared driver + one shared server; tests must not overlap public class SnippetTests { private static ChromeDriver _driver = null!; private static string _baseDir = null!; private static string _template = null!; + private static SimpleHttpServer _server = null!; [ClassInitialize] public static void ClassInit(TestContext context) @@ -43,12 +45,14 @@ public static void ClassInit(TestContext context) _baseDir = FindBaseDirectory(); _template = File.ReadAllText(Path.Combine(_baseDir, "JavaScriptResource.mustache")); _driver = CreateDriver(); + _server = new SimpleHttpServer(); } [ClassCleanup] public static void ClassCleanup() { _driver?.Quit(); + _server?.Dispose(); } [TestMethod] @@ -157,8 +161,8 @@ await Promise.race([ private void RunTestInBrowser(string html, string propertyName) { - using var server = new SimpleHttpServer(html); - _driver.Navigate().GoToUrl($"http://localhost:{server.Port}/"); + _server.Content = html; + _driver.Navigate().GoToUrl($"http://localhost:{_server.Port}/"); // Poll for #status to exist AND leave "pending". GoToUrl can return // before Chrome has swapped in the served document, so grabbing the @@ -265,32 +269,54 @@ private static string ConvertToPropertyName(string fileName) // Serve over localhost HTTP, not file://. These snippets write document.cookie // and use sessionStorage; file:// gives an opaque origin where cookies no-op and // storage leaks across pages, changing behavior. Harden the server, don't switch. +// +// Bound ONCE for the whole test class and reused: each test swaps Content and the +// accept loop resolves it per request. Constructing/disposing a listener per test +// churned the port through TIME_WAIT on Linux, which starved later binds and +// surfaced as "#status never left pending" timeouts. class SimpleHttpServer : IDisposable { private readonly HttpListener _listener; private readonly Thread _thread; - private readonly string _content; + private volatile string _content = ""; private bool _running; public int Port { get; } - public SimpleHttpServer(string content) + /// HTML served for the next request. Set before navigating. + public string Content { - _content = content; - _listener = new HttpListener(); - - for (int port = 8765; port < 9000; port++) + get => _content; + set => _content = value; + } + + public SimpleHttpServer() + { + // Bind a free port once. Fresh HttpListener per attempt: a failed Start() + // leaves the prefix attached, so reusing one listener would re-try the + // already-failed prefix and never bind, leaving Port=0. + HttpListener? bound = null; + for (int port = 8765; port < 9000 && bound == null; port++) { + var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/"); try { - _listener.Prefixes.Add($"http://localhost:{port}/"); - _listener.Start(); + listener.Start(); + bound = listener; Port = port; - break; } - catch { } + catch + { + try { listener.Close(); } catch { } + } } + if (bound == null) + throw new InvalidOperationException( + "SimpleHttpServer could not bind any port in range 8765-8999."); + _listener = bound; + _running = true; _thread = new Thread(() => { From 1bcc866647cc88292452f2cc3daa6901849b2f07 Mon Sep 17 00:00:00 2001 From: Maksym Kucherov Date: Thu, 17 Sep 2026 23:01:29 +0200 Subject: [PATCH 8/8] CI: Fix test results reporting. --- ci/run-integration-tests.ps1 | 8 ++++++++ test-results/.gitignore | 5 +++++ tests/template-tests.js | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 test-results/.gitignore diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index c1a3492..c3633c0 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -104,7 +104,15 @@ $TestProject = [IO.Path]::Combine( $RepoPath, "tests", "FiftyOne.JavascriptTemplateTests", "FiftyOne.JavascriptTemplateTests.csproj") +# The EnricoMi publish step in common-ci globs test-results/integration/**/*.trx +# under the repo root. dotnet test defaults the TRX to the project's own +# TestResults/ dir, which that glob never matches (results silently unreported), +# so pin --results-directory to the location the reporter searches. +$ResultsDir = [IO.Path]::Combine($RepoPath, "test-results", "integration") +New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null + dotnet test $TestProject -c $Configuration ` + --results-directory $ResultsDir ` --logger "console;verbosity=normal" ` --logger "trx;LogFileName=snippet-integration.trx" diff --git a/test-results/.gitignore b/test-results/.gitignore new file mode 100644 index 0000000..04c231b --- /dev/null +++ b/test-results/.gitignore @@ -0,0 +1,5 @@ +# This directory is populated with test result reports (JUnit XML / TRX) +# during CI so the results-publishing step can pick them up. +# Do not commit generated test results to version control. +* +!.gitignore diff --git a/tests/template-tests.js b/tests/template-tests.js index 22bedde..46b59fc 100644 --- a/tests/template-tests.js +++ b/tests/template-tests.js @@ -27,19 +27,55 @@ const template = fs.readFileSync(templatePath, 'utf8'); let failures = 0; let checks = 0; +// Accumulated per-check results so the run can emit a JUnit XML report. The CI +// publish step globs test-results/unit/**/*.xml; without a file it reports +// nothing, so console PASS/FAIL alone leaves the summary empty. +const results = []; +let currentSection = 'template-tests'; function check(name, condition, detail) { checks++; if (condition) { console.log(' PASS ' + name); + results.push({ section: currentSection, name: name, failure: null }); } else { failures++; console.log(' FAIL ' + name + (detail ? '\n ' + detail : '')); + results.push({ section: currentSection, name: name, failure: detail || 'assertion failed' }); } } function section(name) { + currentSection = name; console.log('\n=== ' + name + ' ==='); } +function xmlEscape(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} +function writeJUnitReport() { + const outDir = path.join(__dirname, '..', 'test-results', 'unit'); + fs.mkdirSync(outDir, { recursive: true }); + const cases = results.map(function (r) { + const cls = xmlEscape(r.section); + const nm = xmlEscape(r.name); + if (r.failure === null) { + return ' '; + } + return ' \n' + + ' \n' + + ' '; + }).join('\n'); + const xml = '\n' + + '\n' + + ' \n' + + cases + '\n' + + ' \n' + + '\n'; + fs.writeFileSync(path.join(outDir, 'template-tests.xml'), xml, 'utf8'); +} + // --------------------------------------------------------------------------- // The model, matching JavaScriptResource.AsDictionary in pipeline-dotnet. // --------------------------------------------------------------------------- @@ -1529,5 +1565,6 @@ section('A page whose publisher turned cookies on'); } console.log('\n' + checks + ' checks, ' + failures + ' failures'); + writeJUnitReport(); process.exit(failures === 0 ? 0 : 1); })();