ChangeAtlas Logo

ChangeAtlas: Mapping a release onto your system’s dependency graph

Whatever your release process looks like, at some point the release gets summarised as a list of work items. It might be read out in a sign-off meeting, pasted into a handover to the ops team, or attached to a change ticket. Mine is a handover document. The list is useful, but it’s essentially a list of intentions. A title says what a story set out to do. It doesn’t say which parts of the system the code touched, what depends on those parts, or that the some component has been quietly hiding away in there.

I wrote [ ChangeAtlas ] to take the guesswork out of that, after combining some experiments that I had been trying out. You point it at the tracker query for a release (an ADO query in my case) and it gives you one HTML file: a dependency graph of your system, coloured in by what the release did to it. This is the sample that ships with it, a made-up web shop:

What changed in the system map view

Full system map view

The accessible list view

AI once, deterministic forever

You use your own AI agent (Claude Code, Cursor, Copilot — the repo ships the prompts) to scan your repos and produce a JSON graph of components and dependencies: the atlas. You curate it by hand, check it in, and it never regenerates behind your back. From then on, every release is deterministic: work items, pull requests, and changed file paths go in; shading comes out. Same input, same map. No AI at render time means no hallucinated components in a report you’re signing releases off against; and nothing sent to a model at render time either.

A typical ADO query

Five tiers, and what they refuse to count

Every component lands in exactly one tier, decided by production files only:

  • Changed – enough production files matched to call it a real change (threshold is tunable).
  • Touched – some production evidence, below the threshold.
  • Test-only – test files moved, production didn’t. Worth a raised eyebrow either way.
  • Peripheral – not modified, but one dependency edge away from a changed component. This amber ring is your regression shortlist.
  • Untouched – dimmed, but still on the map, because shape is context.

Just as telling is what deliberately doesn’t shade. A NuGet version bump never lights a component up – lockfiles and project manifests don’t count as evidence. Database nodes shade only on schema evidence (migrations, SQL, model snapshots), never on ordinary data-access churn. And a repo’s catch-all glob can’t spread the peripheral ring, so one sprawling repo doesn’t turn the whole map amber. False alarms are how testers learn to ignore a dashboard; the refusals matter as much as the shading.

There are three views – the impact map, a plain system-architecture view, and a keyboard-navigable list view (the page targets WCAG 2.2 AA; the list view is the accessible equivalent of the canvas). Legend pills toggle tiers into the background rather than deleting them, there’s a dark/light theme that follows your OS, and an Export to Obsidian button that turns the current view into a vault – one wikilinked note per component, with graph-view colour groups preset to match the map. You can also export to PNG to embed into other collateral.

Obsidian output

Try it in two lines

No pip installs, no tokens — the sample runs entirely offline:

git clone https://github.com/jholsgrove/ChangeAtlas && cd ChangeAtlas
python -m changeatlas --sample

Or skip even that: the [ live demo ] is the same sample, rebuilt from main on every push. Azure DevOps is supported out of the box; any other tracker works by producing one documented JSON file. There’s also an --anonymize flag that keeps a real release’s shape while replacing every name and link – handy for demos of maps you can’t show.

Or set it up with your agent. Whatever set up you have, get your agent to work with it and submit a pull request.

The honest caveats

The map is only as good as the atlas. The AI-drafted graph needs human curation, it is worth spending time here to get it right, and the file-to-component glob map needs maintaining as the codebase moves – there’s a --check-map validator, but it’s a gardening job, not a one-off. And tier thresholds are judgement calls: three production files might be a real change in one codebase and noise in another, which is why it’s a flag and not a constant.

ChangeAtlas is MIT-licensed and on GitHub. If you try it on your own system’s atlas, let me know how it goes. I would love to invite collaborators to submit pull requests so that it supports all kinds of different set ups.

Update

Newly added large system mode. Check out the demo here: [ 100 repo system ]

MCP Icon

Unit Test Helper with Azure DevOps MCP

This post documents my experimentation with getting an automated AI workflow for adding better unit test coverage to existing classes and getting the PR submitted, almost hands free. It is written quite tersely for brevity. For better demonstration/ explanation I highly recommend this webinar from Boldare & Milena Cylińska which highly inspired this MCP workflow.

Steps to get going

  • Set up a PAT in Azure DevOps with Read/Write/Manage to Code and Work Items.
  • Install DevOps MCP server in Visual Studio Code
  • Add the PAT to the MCP Config
  • Download and install Azure CLI – az login
  • Clone your project inside the repo
  • Start the MCP server

Install the MCP Server

Under Extensions > MCP Servers > Browse/ Install or just install from the link above

PAT

You need a personal access token for the MCP server to use, so it has permissions to see what you see in ADO. Do not share it with anyone, for obvious reasons.

Add it to config

You can add this to config by clicking the cog next to the Azure Devops MCP server and selecting Show Configuration (JSON)

		"azure-devops": {
			"type": "stdio",
			"command": "npx",
			"args": [
				"-y",
				"@azure-devops/mcp@latest",
				"${input:ado_org}"
			],
			"gallery": true,
			"version": "0.0.1",
			  "env": {
    "AZURE_DEVOPS_PAT": "<YOUR PAT HERE>"
  }

Install Azure CLI

Get it here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest

Authenticate in a terminal by command

az login

Leave this open/ running.

Start the MCP Server

This is under the same MCP cog… Hit Start Server.

You know it’s running successfully when you see this in the terminal:

2025-09-26 14:46:13.186 [info] Discovered 70 tools

Usage

Ok now we can start doing something with it. We have two folders: chatmodes and prompts. Chatmodes has the step by step we want the AI to do and the prompts contain the prompts we want it to do.

My example does:

  • Edits unit test classes that you provide to it
  • Creates a branch based from main/master
  • Adds, commits and pushes
  • Links the ADO work item
  • Adds a PR reviewer of your choice

Let’s not forget you can add other ADO prompts in here, for example you could write one to breakdown a story from a list of bullet points in the story, or get it to write a bunch of acceptance tests from the acceptance criteria, in a format of your choosing, get some Playwright tests out of it. So you can see how this can be expanded and how you can potentially chain prompts and workflows together (see video at the top for more on that).

Prompt it

In our chat window lets put our prompt:

Run the whole unitTester workflow for a provided C# unit test class or method. If this is unknown, ask the user to provide it.

This runs the guts of the operation, which is a markdown file with specified tools to use:

---
description: "unitTester — Copilot Chat Mode"
model: GPT-4.1 (copilot)
tools: ['edit', 'runNotebooks', 'search', 'new', 'runCommands', 'runTasks', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'fetch', 'extensions', 'runTests', 'azure-devops']
---

You can see azure-devops on the end there, the rest are built in goodies. You can explore what you have and want to make available in the configure tools menu

The rest of the markdown is broken down into headers where we can also specify how we use a tool, I reference only Azure DevOps in my chatmode file.

Let’s have a quick look at how the flow worked, I won’t put the whole conversation – that would be tedious to read – But I gave it a unit test file to target, that exists already and said go and suggest more test cases, consider edge conditions and the like:

At this point I had a look at what it did, ran them manually and cleared up a few things it got wrong. It fell foul of some SonarLinting rules, which I edited my instructions about, so a minor infraction.

Continue

Moving on I am getting asked if I would like a branch made from main/master and it makes one in the format I requested, adds, commits, pushes and links to the story in ADO I want it linked to.

Lastly, I ask it to add a colleague to review the changes. Job done! (Unless you get PR comments 🙂 )

That all took about 10mins and added some better coverage.

Finally

  • I found this works best on smaller classes – you can certainly hit limits in the amount you can throw at the AI. If you have a 10,000 line unit test class for example, don’t do it or chunk larger inputs.
  • You can most likely get your unit tests to run as part of the flow; I did not cover that and time constraints stopped me exploring further. EDIT: Added this in later by telling it to run via dotnet test – now it goes through several iterations self healing or until a human steps in to straighten it out.
  • I should probably split this into multiple workflows: One to the do the class editing and another to do the ADO management; currently they are lumped together.

Browser Use Logo

Agentic AI with Browser-Use

I recently dipped my toes into Agentic AI with a tool called Browser-Use and was quite impressed with what it could do, so this article is basically my findings with that. Seems to be a bit of a hot topic in testing lately and having seen some controversial takes on it by various doom sayers on LinkedIn.

First off this isn’t the only open source/ free tool out there of course – you could also look at Goose and Selenium MCP which I have not used it but Beth Marshall has done a good intro which you can read up on here 👉 https://beththetester.wordpress.com/2025/03/03/exploring-agentic-ai-with-blocks-goose-mcp-tips-and-demos/

So let’s get started, as with Goose when using a windows machine we need to run this in WSL

wsl --install

Now make sure Python 3.11 + is installed

Browser-Use also has a GUI which is nice but you can of course use the terminal but we’ll start with pulling down the web-ui repo

git clone https://github.com/browser-use/web-ui.git
cd web-ui

The start up guide recommends to set up a virtual environment, which you can do like so (You may need to install the uv package prior to this)

uv venv --python 3.11

And then activate this virtual environment (this is bascally a warded off bit of of the OS so nothing else gets messed with)

source .venv/bin/activate

The stuff that we cloned should have a text file called requirements, which we will now install

uv pip install -r requirements.txt

This tool uses Playwright in order to do the browser bits, so lets get that:

playwright install

Lastly, copy the .env file – we can tweak configuration in here so if you want to use your Ollama instance, you can or put in your API key for whatever LLM platform you like to use.

cp .env.example .env

Then sudo nano .env to edit it to your liking.

Phew! Ok now we’re ready to get going, run the following and go to localhost:7788 in a browser window:

python webui.py --ip 127.0.0.1 --port 7788

You’ll see a few tabs here where you can do more configuration stuff, like the browser window and what LLM/ platform you want to use. Mines setup to use Azure OpenAi/ Gpt-4o. The more grunt the better with this but if I had a beefy machine using Ollama I’d go for that, I love Ollama and I like keeping things in house with AI tooling where possible.

Once you’re done with that head over to the Run Agent tab. We can now run tests headlessly or not depending on your desired config by just describing what we want and then agent will work it all out (in theory at least). No coding in sight.

Look at it go, afterwards you can even get a recording… pretty nifty.

The console also outputs it’s thinking:

I ran some use cases for the product I work on and was quite impressed with the results, it managed to work it out though I was quite specific in my instructions. I can see how something like this could be used in a CI/CD pipeline to test a small amount of critical user journeys. No code or maintenance required (Unless it can’t figure it out after some big UI change).

But you can also see why you wouldn’t want to expand it out beyond this… it will get costly with token use and it is slow. Furthermore a tool like this would be overkill for checking say some backend validation (just fire off an API call) or to check some sub element of some UI dialog is disabled with certain permissions (Selenium/ Playwright would be way quicker). As ever it’s about picking the right tool for the right job.

And that’s that really – It’ll be interesting to see how these kind of tools evolve in future.

Ollama – it really whips the Llama’s ass!

EDIT: You no longer need WSL to run natively on Windows!

I went down a bit of an AI rabbit hole recently in order to boost productivity, we pretty much all use ChatGPT and the like almost as a replacement to a search engine and that’s great but theirs some things AI doesn’t know about: The product I work on or the thing I am studying or about my own life in general.

I want to use AI to help me in my day to day, not just in a Quality Assurance sense (though it helps!) Lets have a look at hosting your own private ChatGPT style knowledgebase that can interrogate stuff that shouldn’t be publicly available. Along comes Ollama.

Prerequisites

I am running all of the below using WSL (Windows Subsystem for Linux), to get started just open up a terminal and type:

wsl --install

Then just run a couple of update commands:

sudo apt update
sudo apt upgrade

That’s it, you’re ready to go! Forewarning though, if you have a computer with a good GPU – the faster this stuff will run.

Ollama

Installation is easy:

sudo curl -fsSL https://ollama.com/install.sh | sh

Check its running here: http://localhost:11434/, you should see a simple message: “Ollama is running”

Now lets pull down an LLM; this is great as companies like Meta have invested millions into these LLMs you can just yoink and start using:

ollama pull llama2

Let’s try this out in the terminal now

ollama run llama2 

Aaaaand ask it something!

>>> what is the plot of ghostbusters?

Ghostbusters is a 1984 American supernatural comedy film directed by Ivan Reitman and starring Bill Murray, Dan
Aykroyd, Harold Ramis, and Ernie Hudson as a group of paranormal investigators who start a ghost-catching
business. The plot revolves around the Ghostbusters' encounters with various supernatural entities, including the
evil spirit Gozer (played by Peter MacNicol), who summons the Stay Puft Marshmallow Man, a giant, marshmallow-like
creature that wreaks havoc on New York City.

The film begins with the Ghostbusters... <etc>

CTRL + d to exit this. Now we need a GUI and we’re going to run this in a docker container. Run the following:

''# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list> /dev/null
sudo apt-get update''

And now the repos are up to date we will install all the necessary Docker bits n’ pieces:

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Now we have docker, we have to pull open webui and run it. Here’s how:

sudo docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=http://127.0.0.1:11434 --name open-webui --restart always ghcr.io/open-webui/open-webui:main

We can check this is running correctly by running

sudo docker ps

CONTAINER ID   IMAGE                                COMMAND           CREATED        STATUS                 PORTS     NAMES
30e6c4399fbc   ghcr.io/open-webui/open-webui:main   "bash start.sh"   24 hours ago   Up 5 hours (healthy)             open-webui

That’s running now so go here in a web browser and click sign up – this will create you an admin user: http://localhost:8080/auth

Note: When you come to wanting to update OpenWebUI use this command:

sudo docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once open-webui

A slick UI is now added! We can now add some more models to expand capability… Now there are a bunch of models we can use – they are all listed here so have a peruse to what will be useful to you. For now we’ll get a couple of them, a coding one and one that can work with images.

ollama pull codegemma
ollama pull llava

Once these are down just refresh the browser and you can select the new LLMs at the top of the main window… we’ll try codegemma first:

And if we change the model to Llava, we can upload an image and see what it makes of it:

It’s pretty accurate for the birthday dog but I have found it very hit and miss for images of websites/ dashboards and the like – if you’re looking to get something useful in such images, switching to a lower resolution tends to help with text.

We can even add more than one model into the conversation – this might be useful for codegen and seeing if one model gives you something better than the other one.

Create your own Knowledge base

Things get interesting when we load our own knowledge base, this can be text files, html files, markdown etc. Click the workspace button in the left hand pane and select knowledge then the plus sign

You can add your custom training data into this collection or series of collections… once it is saved we can quiz the LLM about it by starting our query with #

Stable Diffusion

Stable diffusion is basically giving Ollama a text prompt and saying “Go and generate me an image based on this text prompt” – it’s arguably not much use in a work setting but it’s fun to play with. We’re going to set up something called Automatic1111 and hook it into Ollama to do this.

Automatic1111

Install prerequisite packages:

sudo apt install -y make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev libffi-dev liblzma-dev python3-openssl

Further to this we need to install pyenv in order to run Automatic1111

curl https://pyenv.run | bash

If we create a new directory and navigate to it we can store all of our stablediff goodies in a logical place

mkdir stablediff
cd stablediff

And now we need to pull down a shell file and modify it to make it executable:

wget -q https://raw.githubusercontent.com/AUTOMATIC1111/stable-diffusion-webui/master/webui.sh

chmod +x webui.sh

Now run webui.sh

./webui.sh -listen -api

Troubleshooting note 1:  if this fails, run pyenv from within the stablediff directory

pyenv install 3.10
pyenv local 3.10
./webui.sh

Troubleshooting note 2: You may need to install some Nvidia CUDA drivers

If successful, you should be able to open your browser and see a UI for txt2img and bunch of other stuff here: http://localhost:7860/

Give it a try…..here’s “boris johnson riding a donkey in trafalgar square”

Not sure which is the donkey. But it works, lets get this working in the WebUi part of our Ollama installation.

You’ll want to go to settings which can be accessed in the top right hand corner or bottom left and choose admin settings > images

Choose Automatic1111 from the dropdown menu and put http://localhost:7860 as the base URL, also set the Image Generation (Experimental) to on/ true. Now set the default model… this is something like v1-5-pruned-emaonly.safetensors [6ce0161689]

Save. Now when we type a text prompt, an image icon appears under the output… which can generate the image based on the text.

That’s it! Maybe you can use Ollama to leverage LLMs in your team or organisation – the cost is very low apart from the hardware to run it on but even if you don’t share this capability it is still useful to have your own knowledge base running locally.

My laptop has a built in Quadro P620 and whilst using Ollama is slow, it is by no means unusable. I am currently building a knowledgebase with answers to every awkward question I can think of regarding the product I work on and I don’t see how that can not help my team and organisation.

Level up your workflows with Fabric AI

*featured animation credit: Fabric/ Dan Miessler

Prerequisites

I am running all of the below using WSL (Windows Subsystem for Linux), to get started just open up a terminal and type:

wsl --install

Then just run a couple of update commands:

sudo apt update
sudo apt upgrade

That’s it, you’re ready to go!

Fabric

Fabric is a really nifty AI tool that helps people be more productive, in its own words:

fabric is an open-source framework for augmenting humans using AI. It provides a modular framework for solving specific problems using a crowdsourced set of AI prompts that can be used anywhere.

This works by taking an input… let’s say a wall of text from your clipboard and feeding that into a pattern or chain of patterns to give you an AI augmented outcome. I find some useful patterns to use are extract_wisdom and summarise. The maintainers have even approved a pattern I created to help write user stories. So let lets get setup and see what we can do.

Installing Fabric

First up, go get yourself a ChatGPT, Claude and YouTube API key. The first two cost you, but £5 will take you a long way here.

Now run this command:

curl -L https://github.com/danielmiessler/fabric/releases/latest/download/fabric-linux-amd64> fabric && chmod +x fabric && ./fabric --version

You now need to install Go

sudo apt install golang-go -y

Install fabric from Go:

go install github.com/danielmiessler/fabric@latest

Now we can run Setup for Fabric

fabric --setup

Paste in your API keys here and do not forget to set the config marked as required! As a ease of use thing, we are now going to install something called xsel to give us the pbpaste functionality you get on a Mac

PbPaste

sudo apt install xsel

To use this, we need to create an alias in our wsl Ubuntu config, I’m using nano to do this:

sudo nano ~/.bashrc

Now insert the following links and CTRL+X to exit and Y to save

alias pbcopy='xsel --input --clipboard'
alias pbpaste='xsel --output --clipboard'

Usage

Go and find some code, copy it to the clipboard, lets get Fabric to explain it!

pbpaste | fabric -s -p explain_code

The flag -s, streams the output to our terminal window, the -p flag specifies which pattern to use – to see all available patterns click here!

Here is our output:

EXPLANATION:
This C# code defines a static class called RandomStringGenerator with a method called GenerateRandomString. The method takes an integer parameter called length, which specifies the length of the random string to be generated.

Inside the method, a constant string named characters is defined, which contains all the characters that can be used to generate the random string (uppercase letters, lowercase letters, and numbers).

A StringBuilder named randomString is initialized to store the generated random string.

A Random object named random is created to generate random numbers for selecting characters from the characters string.

A for loop is used to iterate from 0 to the specified length. In each iteration, a random index is generated using random.Next(characters.Length), and the character at that index in the characters string is appended to the randomString.

Finally, the generated randomString is converted to a regular string using ToString() and returned.

Overall, this code generates a random string of the specified length using the characters defined and a random number generator.

Neat! What else can we do?

Chaining Commands

We used a pattern just then to explain some code… It’s a little wordy for what I need it for, so lets take the output and pipe that into another fabric command, like so:

pbpaste | fabric -p explain_code | fabric -s -p summarize

Notice we only need to stream on the latter pattern, now we have a summarised version of the explanation:

ONE SENTENCE SUMMARY:
A C# static class defines a method to generate random strings of specified length using characters.

MAIN POINTS:
1. C# static class RandomStringGenerator generates random strings of specified length.
2. The method uses a predefined set of characters (uppercase, lowercase letters, and digits).
3. A StringBuilder stores the generated random string.
4. A Random object generates random numbers for selecting characters.
5. The method iterates 'length' times, selecting characters randomly each time.
6. The selected character is appended to the StringBuilder 'randomString'.
7. The method returns the generated random string using ToString() method.
8. This code snippet provides a utility for generating random strings using a defined character set.
9. It allows customization of the length of the generated random string.
10. The code snippet is a simple and efficient way to generate random strings in C#.

TAKEAWAYS:
1. Understand how to generate random strings in C# using a static class.
2. Learn how to use StringBuilder to efficiently store and manipulate strings.
3. Gain insight into using Random class for generating random numbers in C#.
4. Explore the concept of predefined character sets for generating random strings.
5. This code snippet offers a practical utility for generating random strings with specified length in C#.

Very cool stuff! There’s a whole bunch of things you can use Fabric for to make you more productive – lets have a look at what you can do with a YouTube API Key set.

fabric -y https://youtu.be/4QtEjXyalhs?si=aoTSWyfVrPBAtiKU -s -p extract_wisdom

This command takes the transcript of the YouTube video and gives us a complete breakdown of the extracted wisdom, what a time saver! I might not have the time to watch the video or I might have reservations about whether the video holds content useful to me and helps me make a judgement call whether to actually watch it or find some other content which is more attuned to my needs.

SUMMARY:
The video demonstrates how to build a budget-friendly nano tank for beginners, with tips and plant care advice.

IDEAS:
1. Use glass from frameless picture frames for a budget-friendly tank under $20.
2. Attach blue tack under the ruler when cutting glass for straighter cuts.
3. Score glass once to avoid jagged edges and blunting the glass cutter.
4. Sand glass edges while wet to avoid breathing in glass dust.
5. Apply electrical tape to hold glass pieces together while silicone dries.
6. Make silicone beads wider than glass thickness for better adhesion.
7. Work relatively fast but don't rush when assembling the tank.
8. Use coarse filter foam for a plant-growing background in the tank.
9. Create a retaining wall using slate stones to separate land and water areas.
10. Plug gaps in the retaining wall with filter foam to prevent substrate leakage.
11. Aqua soil or gravel with liquid fertilizer can be used as substrate.
12. Use gel-type super glue to secure wood pieces and add sand for a natural look.
13. Add fine gravel and crushed slate stone for variation in the tank's base.
14. Use plants like Anubias, Bucephalandra, Hydrocotyle, and Alternanthera mini in the tank.
15. Introduce duckweed on the water's surface for detail and springtails for care.

INSIGHTS:
1. Budget-friendly materials like frameless picture frames and coarse filter foam can create a beautiful tank.
2. Proper glass cutting and silicone application are essential for a sturdy tank construction.
3. Careful plant selection and placement can transform a simple tank into a thriving ecosystem.
4. Regular maintenance tasks like water changes and proper lighting are crucial for plant growth.
5. Attention to detail, such as filling gaps in the retaining wall and adding substrate, enhances the tank's aesthetics.

QUOTES:
1. "Cutting glass really isn't as difficult as you might think it is."
2. "I think the perfect plant for this is going to be some Hydrocotyle."
3. "It may not look the best right now, but after a month or two of growth, it's going to completely transform."

HABITS:
- Regular water changes every couple of weeks.
- Use a big syringe for easy water changes.
- Set LED aquarium light on a timer for 12 hours a day.
- Avoid direct sunlight for natural lighting.
- Keep plants trimmed to maintain a balanced ecosystem.

FACTS:
- Coarse filter foam is cost-effective and easy to work with for tank backgrounds.
- Aqua soil or gravel with liquid fertilizer can be used as substrate for plant growth.
- Duckweed multiplies fast and adds detail to the tank's surface.
- Springtails can be introduced for tank maintenance.
- Proper care, such as regular water changes, contributes to plant growth in the tank.

REFERENCES:
- Frameless picture frames
- Glass cutter
- Coarse filter foam
- Aqua soil
- LED aquarium light

ONE-SENTENCE TAKEAWAY:
Building a thriving nano tank on a budget requires careful construction, plant selection, and maintenance practices.

RECOMMENDATIONS:
- Experiment with different plant species to create a diverse ecosystem.
- Regularly monitor and adjust lighting to promote plant growth.
- Incorporate natural elements like wood and stones for a visually appealing tank.
- Stay consistent with maintenance tasks for a healthy and balanced tank ecosystem.
- Join online communities or forums to share experiences and learn from other aquarists.

Give Fabric a try and see how it can help speed up your day. In my capacity as a QA Engineer I have used Fabric to speed up story creation, simplify obtuse documentation, explain code and summarise text and I am sure I will find many more uses for it going forward.

Why not have a try at creating your own patterns to solve problems that you encounter everyday? It’s really easy, take a look at the existing patterns and modify them to solve problems for you!

Desktop Automation: TestLeft with SmartBear

Not long ago I was made aware of some tooling from SmartBear called TestLeft, specifically for testing Windows applications at the Desktop level thanks to a brilliant conference talk I witnessed from Eyk Haneklaus.

TestLeft is not a free tool but it does have a short trial offering which is what I am using for now. It can also be used for web automation, though I remain unconvinced it is better than open source alternatives.

However, it does have many strengths particularly if you have legacy applications or internal tooling you can automate or extend in order to automate.

Other tooling out there which is open source does not seem up to scratch as WinAppDriver seems to have been totally abandoned, AutoIt is far too scripty and Microsoft’s attempt with CodedUi was binned years ago. I am unaware of rival commercial offerings but maybe they exist.

Anyway I decided to give TestLeft a quick whirl to see if it could help me with a desktop app/ legacy part of the system that I work on.

Getting Started

First up is to download and install TestLeft – for some reason the UI Spy tool it provides, errors in Visual Studio (for me at least) but it functions fine as a standalone application. This is used to find the window and elements within you wish to interact with. It also generates code and models in a pretty clean way.

The test I wrote was a simple one with just one step definition to fill out – the aim to grab a label where the wording is “Ok” if all is good:

#Background: 
	#Given The poller is running and connected

Scenario: StatusIsOk
	Then the Status is Ok

Setup and Teardown

So let’s get into it. In my TestRunHook file, after setting up IoC/ Bodi we have our setup and teardown which consists of starting the driver we need, the application/ process itself and killing it afterwards:

        [BeforeTestRun]
        private static void Setup()
        {
            IDriver driver = new LocalDriver();
            Driver = driver;
            // Instantiate helper for UI interactions
            ElementHelper = new TestObjectHelper(driver);

            // Move this to appConfig later
            Driver.Applications.Run("C:\\pathToExe.exe");

            IProcess D7Executable = driver.Find<IProcess>(new ProcessPattern()
            {
                ProcessName = ProcessNames.D7Executable 
            });

            Process = D7Exe;
            Poller = new PollerModel(D7Exe);

            // Get the model and click refresh button
            Poller.FmHiLiMain.RefreshButton.Click();
        }

        [AfterTestRun]
        private static void AfterTestRun()
        {
            Poller.Close();
            Process.Close();
            Process.Terminate();
            Poller.FmHiLiMain.Close();
        }

I am also accessing a model of the application and clicking a refresh button which the UI Spy application generated for me (Right click your app and copy model – see pic above). This was really handy but it did require a little bit of clean up. Not a big deal.

At this point our application is running and waiting to be interacted with. I think I might have gone a little OTT on the teardown too but hey-ho.

Interacting with the GUI

As the test only has one step definition, all I am doing is grabbing a label on GUI and checking the status is “Ok”

        [Then("the Status is Ok")]
        public void ThenTheStatusIsOk()
        {
            var label =     ElementHelper.GetMainWindowLabel(ElementHandles.StatusLabel);
            Assert.That(label, Is.EqualTo("OK"));
        }

We have a helper class injected in the step definition to deal with getting text values from UI/ DOM/ COM (whatever you want to call it)

    internal class TestObjectHelper
    {
        private readonly IDriver Driver;

        public TestObjectHelper(IDriver driver)
        {
            Driver = driver;
        }

        private IProcess GetProcess()
        {
            return Driver.Find<IProcess>(new ProcessPattern
            {
                ProcessName = ProcessNames.D7Executable
            });
        }

        private ITopLevelWindow GetMainWindow()
        {
            return GetProcess().Find<ITopLevelWindow>(new VCLPattern
            {
                ClassName = ClassNames.MainWindow,
                ObjectIdentifier = ElementHandles.MainWindow
            });
        }

        public string GetMainWindowLabel(string label)
        {
            var elementLabel = GetMainWindow().Find<IControl>(new VCLPattern
            {
                ClassName = ClassNames.Label,
                ObjectIdentifier = label
            });

            return elementLabel.GetProperty<string>("Text");
        }
}

This helper injects the driver we instantiated and commons up a bunch of process to use in the methods defined. The basis of this was quickly generated by Spy UI as well. Once the element was located, right clicking and “Copy Identification” got the bits and bobs required. Doing this a few times and you can see where it is a good idea to refactor out the Process and Main Window methods.

Once we have that it’s just a case of calling the main window, finding the control you need and returning the required property. In this case, the text value of the label seen on the UI. You can easily implement your preferred page or object pattern models against this.

The UI Spy tool provides you with a lot of strings for controls, classes and what not which you may end up using several times – so I decided to push these down into constants that can be called and changed in once place should the application change in future.

Finding the correct thing to interact with in the UI Spy tool can take a bit of patience at first but it is certainly worth pointing out the free text filter field which can really help you get to what you’re after a lot faster.

Conclusion

Overall I think TestLeft provides some really nice ways to interact with desktop apps, the code generation gives you minimal “fluff” and their does not appear to be a credible open source alternative which is a shame but at least their is an option out there! You can easily integrate this with C# or Java and into whatever test framework you use. I added some BDD but to begin with it was working just fine as regular NUnit tests. I would definitely recommend giving it a try if you have a need or want to drive things on the desktop.

Time to Reqnroll

Over the years I have written a fair bit about Specflow, it has quite heavy usage in my projects and I still think Cucumber and BDD is a valuable tool for cross team collaboration and specification. Some people don’t like the extra layer of implementation it brings and that’s fair enough… whatever works for you.

I was concerned to hear recently that Specflow seems to have died a bit of a death at the hands of Tricentis who acquired the project a few years back. Thankfully, Gaspar Nagy has forked the Specflow Repo in order to breathe new life into the project.

This is definitely a word that needs spreading as Specflow is a popular and widely used library. Go and read the who/ why/ where/ what over on the project page: https://reqnroll.net/news/2024/02/from-specflow-to-reqnroll-why-and-how/

All the news, how to migrate, how to contribute etc – I have not done any migration yet but with dotnet 8.0 support, that won’t be very far away. Some take-aways:

  • The BDD framework itself and the documentation have been ported to Reqnroll.
  • The “SpecFlow+ LivingDoc Generator” is unfortunately closed-source, so cannot be forked. That sounds like it will be remade.
  • SpecRun is also closed-source but had been deprecated, don’t expect to see this again
  • Specflow Actions might get ported/ reviewed… Sounds like this might be on case by case basis
  • The Visual Studio extension has been fully ported to Reqnroll. It has been modified to work with both Reqnroll and SpecFlow projects to make the transition easier.
  • The Table class is now DataTable but either can be used
  • Expected namespace changes

Finally, many thanks to all of those keeping this going. If you use Specflow in any of your projects, go check out Reqnroll as soon as you can. Why not give the migration guide a peruse as well?

https://docs.reqnroll.net/latest/guides/migrating-from-specflow.html

Hooked on Webhooks for Email content

The product I test has a lot of emails that comes out of it and for a time I have been using a great C# library called MailKit. This was used to interrogate a test mailbox (This was in O365) and assert things are as they should be.

This worked quite well apart from when the mailbox went down for maintenance or some other issue on the Microsoft end occurred. What’s more it was easy to access with basicAuth – which is now deprecated.

So with this access about to break, I needed to implement OAuth2 or seek alternatives.

Setup

What I went for in the end was a solution where emails from the system under test were sent to a test subdomain, something like testemails@testemails.product.com which requires an MX DNS record for the subdomain in order to forward the mails to the right place.

My company use a popular email delivery system called SendGrid and discovered I could leverage the use of an Inbound Parse setting, in order to fire incoming mail from testemails@testemails.product.com to any old webhook of my choosing.

End to end

A good test bed for the whole journey was webhook.site, before changing the inbound parse to point to a custom tool already hosted in Azure. This would save the pennies as I wouldn’t need to fork out for an API key at webhook.site…. not that it is an expensive service by any means, it just made sense to leverage existing infrastructure.

Probably the hardest part of this was making changes to the custom webhooks receiver in order to serve up HTML content. However, an out of the box solution like webhook.site would get an end to end working a lot faster.

Refactoring

Then came the task of actually refactoring the existing tests to retrieve the email content depending if it is plain text or HTML via an API GET call and map the json to a simple object which can be deserialised. All without the need for a mailbox or any authentication at all.

Experimenting with Playwright Sharp

I recently started dabbling with Playwright Sharp having seen they now have (as the name suggests) some C# bindings for the automation library.

In order to try and put it through its paces a bit and decide if it is worth pursuing as a replacement or in addition to our Selenium based tests, I picked out one two more gnarly tests in the suite to do a comparison against. Please note this isn’t some pound for pound comparison of why you should use one tool over another, make your own mind up there 🙂

I’m not looking to shift existing automation prior to Selenium 4 being released as that will have a bunch of cool new stuff to explore too.

However, Playwright Sharp makes a strong case for itself in this space.

What I like about replicating in my hacking about time is that it’s given me a kick to do other things around my framework… like updating to .Net 5 for a start, refactoring bits and pieces and of course looking at Playwright Sharp itself.

First off, it is fast. I like fast, so fast it halved the execution time of the test I was looking at (This particular test I refer to does a bunch of API operations as well as iops on disk, in prep for the final outcome which you see in the browser) and the asynchronistic way that Playwright works can make waiting for DOM elements a lot less fiddly.

Actions, like clicks, auto-wait for UI elements to ensure your scripts are reliable to execute. Wait for precise events for more control and say goodbye to timeouts.

https://playwright.dev/

The Test

A fairly uncommon thing I do in UI tests is to get the background css colour of an element. This is pretty simple with Selenium and also Playwright (once you figure it out that is). This is where Playwright is a little harder to use because it’s not always obviously labelled that you need to call X in order to do Y. However I might be slightly biased here as I’ve been using Selenium for donkeys years now.

Alright, case in point, a small code comparison… I want the background css value of some element.

Selenium

var tileColour = locationTile[0].FindElement(By.TagName(TagName.H4)).GetCssValue("background");

Take the first thing from a collection, find the H4 Tag and give me the CSS value. Pretty clear, pretty easy. I would say so even if you don’t know Selenium so well.

Playwright Sharp

return await Page.EvalOnSelectorAsync<string>($"#heattile{Context.LastLocationCreatedId} > article > div > h4", "element => getComputedStyle(element).backgroundColor");

It’s still one line of code so easy… but easy if you know how. I am selecting the H4 element in a different way here, no big deal. I have to call something named EvalOnSelectorAsync, which takes a DOM CSS selector in this case and a pageFunction to get the value we need on that element, also declaring a type for the response. This took a little bit of time to get right and understand coming to it fresh and new.

Experience doing that will make it a breeze later though, such is life. So I would say Selenium methods are perhaps named better and sometimes better served without doing any JavaScript.

Go Faster Still

In order to speed things up, we can intercept traffic and filter out images for example, depending on how true to a user experience you want to mimic this may or may not be for you.

There is a good example of how to filter images on the Playwright Sharp GitHub page:

using var playwright = await Playwright.CreateAsync(); 

await using var browser = await playwright.Firefox.LaunchAsync(); var context = await browser.NewContextAsync(); 

var page = await context.NewPageAsync(); // Log and continue all network requests 

await page.RouteAsync("**", (route, _) => {     Console.WriteLine(route.Request.Url);     route.ContinueAsync(); }); 

await page.GoToAsync("http://todomvc.com");

Musings and further experiments

In conclusion to this particular exercise, the execution time was halved and I put that down to the awaited async methods when controlling the browser because I know the API and Iops parts of the test haven’t changed.

Another good thing for Playwright Sharp is being able to run tests in Webkit without doing any extra work, just change the config – which is pretty neat. Playwright looks to have some good support for emulated Mobile too, not physical devices however.

I’m only scratching the surface but there is a gamut of other cool features of course, such as Docker, Mocking and Mobile support.

One thing I have yet to try is running tests at scale in parallel on a build agent in a bit of a departure to Selenium Grid. The jury is still out for me but Playwright Sharp impresses so far. Give it a try and see if it can benefit you.

Explore the docs here and checkout the GitHub page here. Plus there are some friendly folk over on the Slack channel.

Selenium Grid with Azure Kubernetes

There are lots of good reasons to be running a Selenium grid in cloud or Kubernetes containers: It’s less maintenance, it’s faster with more reliable network hopping, it’s self healing and it can auto scale. So what do we need to get this off the ground?

So let’s get started, first off we need to login to the portal and create a Kubernetes service/ cluster resource.

Pick subscription & allocate a resource group, my resource group here is called ‘Selenium

Cluster name: I’m calling mine here ‘k8s-grid
Region: (Europe) West Europe (this is one of the regions that allows for virtual nodes which are required for scaling), unlike (Europe) UK South, for example.

Node Size: B4ms (4 cores to allow for some scaling)

Cluster Settings 1

Click next (Node pools) and enable virtual nodes.

Node Pool Settings
Be sure to enable Virtual nodes

Click next (Authentication)

You can choose either a Service principal if you have one, or a slightly easier wrapper around it in System-assigned managed identity. Keep RBAC Enabled with default encryption type.

Auth Settings

Click next (Networking)

Just leave this as it comes.

Click next (Integrations)

I turned off Container monitoring for my MSDN account.

Click next (Review and create)

Validation should pass, click create and wait for it to deploy.

Deployment Complete
After a short time it gets deployed.

Connect to the cluster via CLI

Open powershell or whatever terminal you prefer, login to Azure: 

az login

Now connect to the cluster specifying the Resource Group and Cluster name we gave in a previous step:

az aks get-credentials –resource-group Selenium –name k8s-grid

You should see something like

Merged “k8s-grid” as current context in C:\Users\jholsgrove.kube\config

That’s it, you are connected!

Create the Hub

First create a selenium-hub-deployment.yaml locally with the deployment details (Kubernetes has some good examples here, but I will list them out anyway making the odd tweak)

kubectl create -f selenium-hub-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: selenium-hub
  labels:
    app: selenium-hub
spec:
  replicas: 1
  selector:
    matchLabels:
      app: selenium-hub
  template:
    metadata:
      labels:
        app: selenium-hub
    spec:
      containers:
      - name: selenium-hub
        image: selenium/hub:3.141
        ports:
          - containerPort: 4444
        resources:
          limits:
            memory: "1000Mi"
            cpu: ".5"
        livenessProbe:
          httpGet:
            path: /wd/hub/status
            port: 4444
          initialDelaySeconds: 30
          timeoutSeconds: 5
        readinessProbe:
          httpGet:
            path: /wd/hub/status
            port: 4444
          initialDelaySeconds: 30
          timeoutSeconds: 5

We can now see the pod initialising/ running:

kubectl get pods
PS C:\temp\k8s> kubectl create -f selenium-hub-deployment.yaml
deployment.apps/selenium-hub created
PS C:\temp\k8s> kubectl get pods
NAME                            READY   STATUS              RESTARTS   AGE
selenium-hub-7ff45ff687-swvjg   0/1     ContainerCreating   0          11s
PS C:\temp\k8s>

Next we are going to create a service to allow us to get to the Hub web interface, note we need to make sure the type is set to ‘LoadBalancer‘ and create selenium-hub-svc.yaml:

apiVersion: v1
kind: Service
metadata:
  name: selenium-hub
  labels:
    app: selenium-hub
spec:
  ports:
  - port: 4444
    targetPort: 4444
    name: port0
  selector:
    app: selenium-hub
  type: LoadBalancer
  sessionAffinity: None

To deploy: kubectl create -f selenium-hub-svc.yaml

Now run kubectl get services and note the external IP address that was assigned (this might take a few moments to be assigned)

PS C:\temp\k8s> kubectl get services
NAME           TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)          AGE
kubernetes     ClusterIP      10.0.0.1      <none>        443/TCP          27m
selenium-hub   LoadBalancer   10.0.81.187   20.50.14.88   4444:32455/TCP   19s

We can now navigate to the Hub Console at: http://20.50.14.88:4444/grid/console

Selenium Hub
The empty Hub is now accessible

Create the Nodes

Chrome

kubectl create -f .\selenium-node-chrome-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: selenium-node-chrome
  labels:
    app: selenium-node-chrome
spec:
  replicas: 1
  selector:
    matchLabels:
      app: selenium-node-chrome
  template:
    metadata:
      labels:
        app: selenium-node-chrome
    spec:
      volumes:
      - name: dshm
        emptyDir:
          medium: Memory
      containers:
      - name: selenium-node-chrome
        image: selenium/node-chrome-debug:3.141
        ports:
          - containerPort: 5555
        volumeMounts:
          - mountPath: /dev/shm
            name: dshm
        env:
          - name: HUB_HOST
            value: "selenium-hub"
          - name: HUB_PORT
            value: "4444"
        resources:
          limits:
            memory: "1000Mi"

Firefox

kubectl create -f .\selenium-node-firefox-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: selenium-node-firefox
  labels:
    app: selenium-node-firefox
spec:
  replicas: 1
  selector:
    matchLabels:
      app: selenium-node-firefox
  template:
    metadata:
      labels:
        app: selenium-node-firefox
    spec:
      volumes:
      - name: dshm
        emptyDir:
          medium: Memory
      containers:
      - name: selenium-node-firefox
        image: selenium/node-firefox-debug:3.141
        ports:
          - containerPort: 5900
        volumeMounts:
          - mountPath: /dev/shm
            name: dshm
        env:
          - name: HUB_HOST
            value: "selenium-hub"
          - name: HUB_PORT
            value: "4444"
        resources:
          limits:
            memory: "1000Mi"
            cpu: ".5"

And lets see the nodes being added:

PS C:\temp\k8s> kubectl get pods
NAME                                     READY   STATUS              RESTARTS   AGE
selenium-hub-7ff45ff687-swvjg            1/1     Running             0          5m34s
selenium-node-chrome-778787c6f-x2bdd     0/1     ContainerCreating   0          19s
selenium-node-firefox-5c9444c5db-2mlwh   0/1     ContainerCreating   0          9s

Refresh the grid console and see the nodes: http://20.50.14.88:4444/grid/console

Selenium Grid with Nodes
Selenium Grid with 2 nodes

Scaling the Grid

We can scale by replicating nodes and have them sit idle:

PS C:\temp\k8s> kubectl scale deployment selenium-node-firefox --replicas=10
deployment.extensions/selenium-node-firefox scaled

This adds 10 firefox replicas.

PS C:\temp\k8s> kubectl get pods
NAME                                     READY   STATUS    RESTARTS   AGE
selenium-hub-7ff45ff687-swvjg            1/1     Running   0          15m
selenium-node-chrome-778787c6f-x2bdd     1/1     Running   0          9m58s
selenium-node-firefox-5c9444c5db-2mlwh   1/1     Running   0          9m48s
selenium-node-firefox-5c9444c5db-66thg   0/1     Pending   0          47s
selenium-node-firefox-5c9444c5db-b54l9   0/1     Pending   0          47s
selenium-node-firefox-5c9444c5db-hdqmk   1/1     Running   0          9m48s
selenium-node-firefox-5c9444c5db-hhh88   1/1     Running   0          47s
selenium-node-firefox-5c9444c5db-j448l   0/1     Pending   0          47s
selenium-node-firefox-5c9444c5db-ltd2w   0/1     Pending   0          47s
selenium-node-firefox-5c9444c5db-q9pd2   0/1     Pending   0          47s
selenium-node-firefox-5c9444c5db-xfgdt   0/1     Pending   0          47s
selenium-node-firefox-5c9444c5db-xxbr6   0/1     Pending   0          47s`

We can also autoscale horizontally when all of the pods cpu-percentage is more than x% – if this threshold is passed a new pod will be created to run:

PS C:\temp\k8s> kubectl autoscale deployment selenium-node-chrome --cpu-percent=50 --min=1 --max=10
horizontalpodautoscaler.autoscaling/selenium-node-chrome autoscaled

Kubernetes: What if things go wrong?

If a pod crashes we can delete it using the command: kubectl delete pod <podname>

After doing this, the pod will automatically be recreated in an effort to self heal.Edit

Some useful kubectl commands

kubectl get deployments
PS C:\temp\k8s> kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
selenium-hub 1/1 1 1 16m
selenium-node-chrome 1/1 1 1 11m
selenium-node-firefox 3/10 10 3 11m
kubectl get services

kubectl get hpa -w (see replicas increase and decrease as a test run is being executed)

kubectl exec selenium-hub-7ff45ff687-swvjg – printenv (See config for a pod)

PS C:\temp\k8s> kubectl exec selenium-hub-7ff45ff687-swvjg -- printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=selenium-hub-7ff45ff687-swvjg
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.0.0.1
KUBERNETES_SERVICE_HOST=10.0.0.1
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.0.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.0.0.1:443
KUBERNETES_PORT_443_TCP_PROTO=tcp
DEBIAN_FRONTEND=noninteractive
DEBCONF_NONINTERACTIVE_SEEN=true
TZ=UTC
HOME=/home/seluser
GRID_MAX_SESSION=5
GRID_NEW_SESSION_WAIT_TIMEOUT=-1
GRID_THROW_ON_CAPABILITY_NOT_PRESENT=true
GRID_JETTY_MAX_THREADS=-1
GRID_CLEAN_UP_CYCLE=5000
GRID_BROWSER_TIMEOUT=0
GRID_TIMEOUT=1800
GRID_DEBUG=false
GRID_HUB_PORT=4444
GRID_HUB_HOST=0.0.0.0

kubectl logs <podname> (See logs from a node, for example)

PS C:\temp\k8s> kubectl logs selenium-node-chrome-778787c6f-x2bdd
2020-06-19 08:47:14,852 INFO Included extra file "/etc/supervisor/conf.d/selenium.conf" during parsing
2020-06-19 08:47:14,854 INFO supervisord started with pid 7
2020-06-19 08:47:15,856 INFO spawned: 'xvfb' with pid 10
2020-06-19 08:47:15,857 INFO spawned: 'selenium-node' with pid 11
2020-06-19 08:47:16,858 INFO success: xvfb entered RUNNING state, process has stayed up for > than 0 seconds (startsecs)
2020-06-19 08:47:16,858 INFO success: selenium-node entered RUNNING state, process has stayed up for > than 0 seconds (startsecs)
Connecting to the Hub using the host selenium-hub and port 4444
08:47:17.534 INFO [GridLauncherV3.parse] - Selenium server version: 3.141.59, revision: e82be7d358
08:47:17.937 INFO [GridLauncherV3.lambda$buildLaunchers$7] - Launching a Selenium Grid node on port 5555
2020-06-19 08:47:18.161:INFO::main: Logging initialized @1275ms to org.seleniumhq.jetty9.util.log.StdErrLog
08:47:18.944 INFO [WebDriverServlet.<init>] - Initialising WebDriverServlet
08:47:19.244 INFO [SeleniumServer.boot] - Selenium Server is up and running on port 5555
08:47:19.244 INFO [GridLauncherV3.lambda$buildLaunchers$7] - Selenium Grid node is up and ready to register to the hub
08:47:19.435 INFO [SelfRegisteringRemote$1.run] - Starting auto registration thread. Will try to register every 5000 ms.
08:47:20.276 INFO [SelfRegisteringRemote.registerToHub] - Registering the node to the hub: http://selenium-hub:4444/grid/register
08:47:20.435 INFO [SelfRegisteringRemote.registerToHub] - The node is registered to the hub and ready to use

Running Tests

Simple, just point them at your new grid! A very crude test example is below:

[TestMethod]
    public void TestMethod1()
    {
        var options = new ChromeOptions();
        options.AddArgument("--headless");
        options.AddArgument("--no-sandbox");
        options.AcceptInsecureCertificates = true;
        var driver = new RemoteWebDriver(new Uri("http://20.50.172.134:4444/wd/hub/"), options.ToCapabilities());
        driver.Navigate().GoToUrl("https://www.google.com");
        driver.FindElementById("hplogo");
        driver.Quit();
        driver.Dispose();
        driver.Close();
}