From 9b1895391a10c414e2fa6d0f6fd7288fd1f80268 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 16 Sep 2026 22:30:37 +0100 Subject: [PATCH 1/4] FEAT: Run the Selenium contract tests against the on-premise web example, and fail the job when they fail The on-premise getting started web example now takes its port from PORT, its data file from 51DEGREES_DD_PATH (config.json is still used when the variable is not set), serves the client-side script from /51Degrees.core.js and renders the device id, matching the cloud example. ci/run-integration-tests.ps1 now runs the contract tests against both the cloud example (port 8097) and the on-premise example (port 8098, against the TAC data file), writes each result as a trx file with the other integration results, and throws when any run fails. The script is dot-sourced by common-ci, so the exit code of dotnet test was lost once the performance step ran its own commands, which is why four failing contract tests were reported as success. --- ci/run-integration-tests.ps1 | 131 ++++++++++++++---- .../example_utils.py | 9 ++ .../onpremise/gettingstarted_web/app.py | 52 ++++++- .../gettingstarted_web/templates/index.html | 20 ++- .../tests/test_onpremisegettingstartedweb.py | 42 ++++++ 5 files changed, 211 insertions(+), 43 deletions(-) diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index b9bca8dc9..f86013eb3 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -36,37 +36,110 @@ if ($env:GITHUB_JOB -eq "Test") { $env:_51DEGREES_RESOURCE_KEY = $Keys.TestResourceKey ./python/run-integration-tests.ps1 -RepoName $RepoName -Packages $packages -Keys $Keys -$status = $LASTEXITCODE +$failures = @() +if ($LASTEXITCODE -ne 0) { + $failures += "the Python integration tests exited with code $LASTEXITCODE" +} + +# The shared Selenium contract tests are run against the cloud example and the +# on-premise example in turn. Each result is also written as a trx file next +# to the other integration results, so it is published with them. +$resultsDir = (New-Item -ItemType Directory -Force -Path "$RepoName/test-results/integration").FullName + +# Get the shared contract tests. +if (-not (Test-Path selenium-api-tests)) { + git clone --depth 1 https://github.com/51Degrees/selenium-api-tests.git + if ($LASTEXITCODE -ne 0) { throw "failed to clone selenium-api-tests" } +} -Write-Host 'Running Selenium tests...' +# Settings the suite reads whichever example it drives. CLOUD_ROOT_URL is read +# when the suite starts, so it is needed for the on-premise run too. +$env:CLOUD_ROOT_URL = "https://cloud.51degrees.com/" +$env:PAID_RESOURCE_KEY = $Keys.TestResourceKey +$env:EXAMPLE_LANG = 'python' + +# The examples run from their own virtual environment, created once here so a +# running example never holds a file the second setup would need to replace. +$examplesDir = (Resolve-Path "$PSScriptRoot/../fiftyone_devicedetection_examples").Path +$py = Join-Path $examplesDir ($IsWindows ? ".venv/Scripts/python.exe" : ".venv/bin/python") +Push-Location $examplesDir try { - # Start this repo's cloud example, pointed at the live cloud. - Push-Location "$PSScriptRoot/../fiftyone_devicedetection_examples" + python3 -m venv .venv + if ($LASTEXITCODE -ne 0) { throw "failed to create the examples virtual environment" } + & $py -m pip install -e . + if ($LASTEXITCODE -ne 0) { throw "failed to install the examples package" } +} finally { Pop-Location } + +# Starts one of this repository's web examples on the given port, waits for it +# to answer, runs the contract tests against it and stops it again. Returns a +# description of the failure, or nothing when every test passed. +function Test-Example([string]$Name, [string]$Module, [int]$Port) { + Write-Host "Running Selenium tests against the $Name example on port $Port..." + $example = $null try { - python3 -m venv .venv - $py = $IsWindows ? ".venv/Scripts/python.exe" : ".venv/bin/python" - & $py -m pip install -e . - $env:PORT = 8097 - $env:_51DEGREES_RESOURCE_KEY = $Keys.TestResourceKey - $env:cloud_endpoint = "https://cloud.51degrees.com/api/v4/" - $example = & $py -m fiftyone_devicedetection_examples.cloud.gettingstarted_web 2>&1 & - } finally { Pop-Location } - - # Get the shared contract tests. - if (-not (Test-Path selenium-api-tests)) { - git clone --depth 1 https://github.com/51Degrees/selenium-api-tests.git + Push-Location $examplesDir + try { + $env:PORT = $Port + $example = & $py -m $Module 2>&1 & + } finally { Pop-Location } + + # Wait for the example to come up, or to stop. + $url = "http://localhost:$Port" + $deadline = (Get-Date).AddSeconds(180) + $ready = $false + while (-not $ready -and (Get-Date) -lt $deadline -and $example.State -eq 'Running') { + try { + Invoke-WebRequest -Uri $url -TimeoutSec 10 -SkipHttpErrorCheck | Out-Null + $ready = $true + } catch { + Start-Sleep -Seconds 2 + } + } + if (-not $ready) { + return "the $Name example did not answer on $url (job state $($example.State))" + } + + $env:EXAMPLE_URL = $url + # Output goes to the host, so that only a failure is returned. + dotnet test selenium-api-tests -c Release --filter TestCategory=Contract ` + --results-directory $resultsDir --logger "trx;LogFileName=selenium-$Name.trx" ` + --logger "console;verbosity=normal" | Out-Host + if ($LASTEXITCODE -ne 0) { + return "the Selenium contract tests against the $Name example exited with code $LASTEXITCODE" + } + } finally { + if ($example) { + Write-Host ">>> $Name example output >>>" + Receive-Job $example | Out-Host + Write-Host "<<< $Name example output <<<" + Remove-Job -Force $example + } } - # Wait for the example to come up. - curl -sS -o /dev/null --retry 5 --retry-connrefused "http://localhost:$env:PORT" - - $env:CLOUD_ROOT_URL = "https://cloud.51degrees.com/" - $env:PAID_RESOURCE_KEY = $Keys.TestResourceKey - $env:EXAMPLE_URL = "http://localhost:$env:PORT" - $env:EXAMPLE_LANG = 'python' - dotnet test selenium-api-tests -c Release --filter TestCategory=Contract -} catch { - if ($example) { Write-Host '>>> example app output >>>'; Receive-Job $example | Out-Host; Write-Host '<<< app output <<<' } - throw -} finally { - if ($example) { Remove-Job -Force $example } +} + +# The cloud example, pointed at the live cloud. +$env:_51DEGREES_RESOURCE_KEY = $Keys.TestResourceKey +$env:cloud_endpoint = "https://cloud.51degrees.com/api/v4/" +$failures += Test-Example -Name 'cloud' ` + -Module 'fiftyone_devicedetection_examples.cloud.gettingstarted_web' -Port 8097 + +# The on-premise example. The contract tests need the device type and the +# JavaScript properties, which the Lite data file does not have, so it runs +# against the TAC data file, which is only fetched when a licence is given. +$tac = "$PWD/assets/TAC-HashV41.hash" +if (Test-Path $tac) { + ${env:51DEGREES_DD_PATH} = (Resolve-Path $tac).Path + $failures += Test-Example -Name 'onpremise' ` + -Module 'fiftyone_devicedetection_examples.onpremise.gettingstarted_web' -Port 8098 + Remove-Item Env:51DEGREES_DD_PATH +} else { + Write-Output "::warning file=$($MyInvocation.ScriptName),line=$($MyInvocation.ScriptLineNumber),title=No TAC Data File::The TAC data file wasn't found, so the Selenium tests will not run against the on-premise example." +} + +# A failure anywhere above must fail the job. This script is dot-sourced by +# common-ci, so an exit code is lost once a later step runs a command of its +# own, and only an error that stops the script is seen by the job. +$failures = @($failures | Where-Object { $_ }) +if ($failures.Count -gt 0) { + throw "Integration tests failed: $($failures -join '; ')" } diff --git a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/example_utils.py b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/example_utils.py index 236c6f937..8e66f26ec 100644 --- a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/example_utils.py +++ b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/example_utils.py @@ -46,6 +46,11 @@ class ExampleUtils: ENDPOINT_ENV_VAR = "cloud_endpoint" + # The environment variable an on-premise example reads the path of its + # device detection data file from, when one is given, in place of the + # file named in its configuration. + DATA_FILE_ENV_VAR = "51DEGREES_DD_PATH" + @staticmethod def get_resource_key_from_config(config): key = "" @@ -163,6 +168,10 @@ def get_missing_resource_key_message(): "read). Create a resource key for free at " "https://configure.51degrees.com?utm_source=code&utm_medium=example&utm_campaign=device-detection-python&utm_content=fiftyone_devicedetection_examples-src-fiftyone_devicedetection_examples-example_utils.py&utm_term=resource-key-required") + @staticmethod + def get_data_file_path(): + return ExampleUtils.__get_env_variable(ExampleUtils.DATA_FILE_ENV_VAR) + @staticmethod def get_cloud_endpoint(): return ExampleUtils.__get_env_variable(ExampleUtils.ENDPOINT_ENV_VAR) diff --git a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py index d5a4f6a23..e22cb5879 100644 --- a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py +++ b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/app.py @@ -99,8 +99,10 @@ def build(self, config, logger): return self def run(self): - - GettingStartedWeb.app.run(port=5001) + + # The port can be set with the PORT environment variable, as it can + # for the cloud example, so the example can run next to other services. + GettingStartedWeb.app.run(port=int(os.environ.get("PORT", 5001))) # First we make a JSON route that will be called from the client side and will return # a JSON encoded property database using any additional evidence provided by the client @@ -125,6 +127,36 @@ def jsonroute(): return json.dumps(flowdata.jsonbundler.json) + # Next we serve the client-side JavaScript from its own route. The other + # Pipeline APIs ship a web integration that intercepts '/51Degrees.core.js' + # for this, so the page can reference the script by that name. There is no + # such integration for Flask, so the example wires up the route itself. + + @staticmethod + @app.route('/51Degrees.core.js') + def core_js(): + + # Create the flowdata object for the JavaScript route + flowdata = GettingStartedWeb.pipeline.create_flowdata() + + # Add any information from the request (headers, cookies and additional + # client side provided information). Query parameters are included, so + # the per-request 'fod-js-enable-cookies' override is picked up here and + # applied by the JavaScriptBuilder engine. + + flowdata.evidence.add_from_dict(webevidence(request)) + + # Process the flowdata + + flowdata.process() + + # Return the JavaScript from the JavaScriptBuilder engine + + response = make_response(flowdata.javascriptbuilder.javascript) + response.headers["Content-Type"] = "application/x-javascript" + + return response + # In the main route we dynamically update the screen's device property display # using the above JSON route @@ -182,6 +214,19 @@ def build_config(): configFile = Path(__file__).resolve().parent.joinpath("config.json").read_text() config = json.loads(configFile) + # A data file named in the 51DEGREES_DD_PATH environment variable takes + # the place of the one in config.json. This lets the example run + # against a paid data file without editing the configuration. + envDataFile = ExampleUtils.get_data_file_path() + if envDataFile: + envDataFile = os.path.abspath(envDataFile) + if not os.path.exists(envDataFile): + raise Exception("The device detection data file " + + f"'{envDataFile}' named in the environment variable " + + f"'{ExampleUtils.DATA_FILE_ENV_VAR}' does not exist.") + ExampleUtils.set_data_file_in_config(config, envDataFile) + return config + dataFile = ExampleUtils.get_data_file_from_config(config) foundDataFile = False if not dataFile: @@ -203,7 +248,8 @@ def build_config(): f"'{dataFile}'. If using the lite file, then make sure the " + "device-detection-data submodule has been updated by running " + "`git submodule update --recursive`. Otherwise, ensure that the filename " + - "is correct in config.json.") + "is correct in config.json, or set the environment variable " + + f"'{ExampleUtils.DATA_FILE_ENV_VAR}' to the path of the data file.") return config diff --git a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/templates/index.html b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/templates/index.html index a315d491e..006bcef71 100644 --- a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/templates/index.html +++ b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/templates/index.html @@ -141,6 +141,9 @@

Device data

Browser Version:{{ utils.get_human_readable(data.device, "browserversion") }} Screen width (pixels):{{ utils.get_human_readable(data.device, "screenpixelswidth") }} Screen height (pixels):{{ utils.get_human_readable(data.device, "screenpixelsheight") }} + {# The device id identifies the matched hardware, platform and browser profiles, + so it is the compact form of everything else in this table. #} + Device Id:{{ utils.get_human_readable(data.device, "deviceid") }} @@ -165,18 +168,13 @@

Client-side evidence and Apple models

{# - This script is constructed by the fiftyone_pipeline_core package. - It adds a JavaScript include for 51Degrees.core.js. - The 51Degrees pipeline will dynamically generate JavaScript, which includes a - JSON representation of the contents of flow data, i.e. the results from device detection. - That script raises the 'complete' event with the refined flow data. The shared - examples.js helper subscribes to it and appends a results table into #content. + 51Degrees.core.js is built by the fiftyone_pipeline_core package and served by the + matching route in app.py. It includes a JSON representation of the contents of flow + data, i.e. the results from device detection, and raises the 'complete' event with the + refined flow data. The shared examples.js helper subscribes to it and appends a results + table into #content. #} -{% autoescape false %} - -{% endautoescape %} + ', response.data) + + +class OnPremiseGettingStartedWebConfigTests(unittest.TestCase): + + # A data file named in the environment replaces the one in config.json. + def test_data_file_from_environment(self): + with tempfile.NamedTemporaryFile(suffix=".hash", delete=False) as file: + path = file.name + try: + with mock.patch.dict(os.environ, {ExampleUtils.DATA_FILE_ENV_VAR: path}): + config = GettingStartedWeb.build_config() + self.assertEqual( + os.path.abspath(path), + ExampleUtils.get_data_file_from_config(config)) + finally: + os.remove(path) + + # A data file named in the environment that does not exist is reported by + # name, rather than the example silently falling back to config.json. + def test_missing_data_file_from_environment(self): + missing = os.path.join(tempfile.gettempdir(), "no-such-51degrees-file.hash") + with mock.patch.dict(os.environ, {ExampleUtils.DATA_FILE_ENV_VAR: missing}): + with self.assertRaises(Exception) as context: + GettingStartedWeb.build_config() + self.assertIn(ExampleUtils.DATA_FILE_ENV_VAR, str(context.exception)) From 8ba11b5a792160fe9efc452630d175e925e71b36 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 16 Sep 2026 22:48:36 +0100 Subject: [PATCH 2/4] TEMP: Print what the examples serve as 51Degrees.core.js (to be reverted) --- ci/run-integration-tests.ps1 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index f86013eb3..c26cb8997 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -99,6 +99,18 @@ function Test-Example([string]$Name, [string]$Module, [int]$Port) { return "the $Name example did not answer on $url (job state $($example.State))" } + # TEMPORARY DIAGNOSTIC + $ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36' + $diag = Join-Path $resultsDir "core-$Name.js" + Invoke-WebRequest -Uri "$url/51Degrees.core.js" -UserAgent $ua -OutFile $diag + Write-Host "DIAG $Name core.js length $((Get-Item $diag).Length)" + node --check $diag 2>&1 | Select-Object -First 15 | ForEach-Object { Write-Host "DIAG node: $_" } + Write-Host "DIAG node exit $LASTEXITCODE" + $text = Get-Content -Raw $diag + Write-Host "DIAG head: $($text.Substring(0, [Math]::Min(300, $text.Length)))" + $jsonPart = [regex]::Match($text, 'var json\s*=\s*(\{.*?\});').Groups[1].Value + Write-Host "DIAG json length $($jsonPart.Length)" + node -e "const fs=require('fs');const t=fs.readFileSync(process.argv[1],'utf8');const w={};const window=w;try{new Function('window','document','navigator','sessionStorage','localStorage',t)}catch(e){console.log('DIAG compile error',e.message)}" $diag 2>&1 | ForEach-Object { Write-Host $_ } $env:EXAMPLE_URL = $url # Output goes to the host, so that only a failure is returned. dotnet test selenium-api-tests -c Release --filter TestCategory=Contract ` From 4813d87d3908c0058e313307598f364c5b1ad105 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 16 Sep 2026 22:50:39 +0100 Subject: [PATCH 3/4] FIX: Add the SequenceElement to the on-premise web example's configuration, so its script parses Without it the client-side script is rendered as 'var sequence=;', which does not parse, so 'fod' is never defined. The first CI run of the contract tests against the on-premise example failed 8 of 9 for this reason. --- .../onpremise/gettingstarted_web/config.json | 4 ++++ .../tests/test_onpremisegettingstartedweb.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/config.json b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/config.json index b355a2e48..d0ceed13a 100644 --- a/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/config.json +++ b/fiftyone_devicedetection_examples/src/fiftyone_devicedetection_examples/onpremise/gettingstarted_web/config.json @@ -11,6 +11,10 @@ "licence_keys": "" } }, + { + "elementName": "SequenceElement", + "elementPath": "fiftyone_pipeline_core.sequenceelement" + }, { "elementName": "JSONBundlerElement", "elementPath": "fiftyone_pipeline_core.jsonbundler" diff --git a/fiftyone_devicedetection_examples/tests/test_onpremisegettingstartedweb.py b/fiftyone_devicedetection_examples/tests/test_onpremisegettingstartedweb.py index 073f316e3..70374c6a1 100644 --- a/fiftyone_devicedetection_examples/tests/test_onpremisegettingstartedweb.py +++ b/fiftyone_devicedetection_examples/tests/test_onpremisegettingstartedweb.py @@ -22,6 +22,7 @@ import flask_unittest import os +import re import tempfile import unittest from unittest import mock @@ -51,6 +52,15 @@ def test_onpremise_getting_started_web_core_js(self, client): self.assertEqual("application/x-javascript", response.headers["Content-Type"]) self.assertIn(b"fiftyoneDegreesManager", response.data) + # The script takes its sequence number from the SequenceElement. Without that + # element in config.json the script is rendered as 'var sequence=;', which + # does not parse, so the browser never defines 'fod'. + def test_onpremise_getting_started_web_core_js_has_sequence(self, client): + response = client.get('/51Degrees.core.js') + self.assertIsNone( + re.search(rb"sequence\s*=\s*;", response.data), + "the script has no sequence number, so it will not parse") + # The page must load the script from that route rather than inline it. def test_onpremise_getting_started_web_references_core_js(self, client): response = client.get('/') From 81a18feca29d115063307efcab7a135bcdb10ac7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 16 Sep 2026 22:50:39 +0100 Subject: [PATCH 4/4] Revert "TEMP: Print what the examples serve as 51Degrees.core.js (to be reverted)" This reverts commit 8ba11b5a792160fe9efc452630d175e925e71b36. --- ci/run-integration-tests.ps1 | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ci/run-integration-tests.ps1 b/ci/run-integration-tests.ps1 index c26cb8997..f86013eb3 100644 --- a/ci/run-integration-tests.ps1 +++ b/ci/run-integration-tests.ps1 @@ -99,18 +99,6 @@ function Test-Example([string]$Name, [string]$Module, [int]$Port) { return "the $Name example did not answer on $url (job state $($example.State))" } - # TEMPORARY DIAGNOSTIC - $ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36' - $diag = Join-Path $resultsDir "core-$Name.js" - Invoke-WebRequest -Uri "$url/51Degrees.core.js" -UserAgent $ua -OutFile $diag - Write-Host "DIAG $Name core.js length $((Get-Item $diag).Length)" - node --check $diag 2>&1 | Select-Object -First 15 | ForEach-Object { Write-Host "DIAG node: $_" } - Write-Host "DIAG node exit $LASTEXITCODE" - $text = Get-Content -Raw $diag - Write-Host "DIAG head: $($text.Substring(0, [Math]::Min(300, $text.Length)))" - $jsonPart = [regex]::Match($text, 'var json\s*=\s*(\{.*?\});').Groups[1].Value - Write-Host "DIAG json length $($jsonPart.Length)" - node -e "const fs=require('fs');const t=fs.readFileSync(process.argv[1],'utf8');const w={};const window=w;try{new Function('window','document','navigator','sessionStorage','localStorage',t)}catch(e){console.log('DIAG compile error',e.message)}" $diag 2>&1 | ForEach-Object { Write-Host $_ } $env:EXAMPLE_URL = $url # Output goes to the host, so that only a failure is returned. dotnet test selenium-api-tests -c Release --filter TestCategory=Contract `