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/template-tests.yml b/.github/workflows/template-tests.yml index 7cb22f2..7e77baf 100644 --- a/.github/workflows/template-tests.yml +++ b/.github/workflows/template-tests.yml @@ -1,37 +1,23 @@ -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. +name: Pull Requests on: pull_request: workflow_dispatch: - -permissions: - contents: read + inputs: + dryrun: + type: boolean + default: false 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 + 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/JavaScriptResource.mustache b/JavaScriptResource.mustache index 565739a..742e6e1 100644 --- a/JavaScriptResource.mustache +++ b/JavaScriptResource.mustache @@ -766,6 +766,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.'); @@ -797,9 +804,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 @@ -883,7 +892,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/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..e16ffa6 --- /dev/null +++ b/ci/fetch-assets.ps1 @@ -0,0 +1,34 @@ +# ********************************************************************* +# 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 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 "TAC-HashV41.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..c3633c0 --- /dev/null +++ b/ci/run-integration-tests.ps1 @@ -0,0 +1,119 @@ +# ********************************************************************* +# 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 = "feature/js-snippet-export" +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +Set-StrictMode -Version 1.0 + +$RepoPath = [IO.Path]::Combine($pwd, $RepoName) + +# 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", "TAC-HashV41.hash")) +if (-not $dataFile) { + Write-Host "::warning::No TAC 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") + +# 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" + +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/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/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/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..369166e --- /dev/null +++ b/tests/FiftyOne.JavascriptTemplateTests/SnippetTests.cs @@ -0,0 +1,346 @@ +/* ********************************************************************* + * 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] +[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) + { + _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] + [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 for manual replication after a run. + 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) + { + _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 + // 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); + } + Assert.IsNotNull(statusEl, + $"{propertyName}: #status never left 'pending' within 10s (page did not load or snippet hung)"); + + 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)); + } + + // 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(); + var snippetsDir = Path.Combine(baseDir, "snippets"); + + 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}"; + } +} + +// 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 volatile string _content = ""; + private bool _running; + + public int Port { get; } + + /// HTML served for the next request. Set before navigating. + public string Content + { + 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.Start(); + bound = listener; + Port = port; + } + 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(() => + { + 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 { } + } +} 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); })();