You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by sh...@apache.org on 2022/05/02 21:19:04 UTC

[trafficcontrol] branch master updated: Add e2e tests for TPv2 Servers table (#6773)

This is an automated email from the ASF dual-hosted git repository.

shamrick pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 96b8efb03f Add e2e tests for TPv2 Servers table (#6773)
96b8efb03f is described below

commit 96b8efb03f4e69566785ebc95ef5b798718c8317
Author: ocket8888 <oc...@apache.org>
AuthorDate: Mon May 2 15:18:59 2022 -0600

    Add e2e tests for TPv2 Servers table (#6773)
    
    * Fix ESLint not properly detecting valid uses of `this`
    
    * Add global definition of a test suite type
    
    * Add generic table methods
    
    * Add servers table page object and tests
    
    * Remove unused function
    
    * Fix incorrect servers page selector
    
    * Add filtering test assertion
    
    * Simplify users page object and tests with new typings
    
    * Simplify login tests with new typings
    
    * Stop cachebusting in all tpv2 workflows
    
    * Move running steps back to action
    
    * Set window size explicitly
    
    * Fix clicking at the wrong time
    
    * Add pause to servers table
    
    * Fix test that couldn't possibly work
    
    * Fix import order
    
    * Rename workflow
    
    * Remove unused dependency
    
    * Upload JUNIT output
    
    * Move configuration change to configuration file
    
    * Skip npm install when cache is valid
    
    * Use npm clean install
---
 .github/actions/tpv2-integration-tests/README.md   |  16 --
 .github/actions/tpv2-integration-tests/action.yml  |  19 ---
 .github/actions/tpv2-integration-tests/cdn.json    |  10 +-
 .../actions/tpv2-integration-tests/entrypoint.sh   |  97 ++---------
 .github/workflows/tpv2.integration.tests.yml       | 115 -------------
 .github/workflows/tpv2.yml                         | 178 ++++++++++++++++++++-
 experimental/traffic-portal/.eslintrc.json         |  13 +-
 .../traffic-portal/nightwatch/globals/globals.ts   |  10 +-
 .../nightwatch/globals/{globals.ts => index.ts}    |  26 +--
 .../nightwatch/globals/tables/index.ts             |  99 ++++++++++++
 .../traffic-portal/nightwatch/nightwatch.conf.js   |   3 +-
 .../nightwatch/page_objects/servers.ts             |  55 +++++++
 .../nightwatch/page_objects/users.ts               |  36 +----
 .../traffic-portal/nightwatch/tests/login.spec.ts  |  19 ++-
 .../nightwatch/tests/servers.spec.ts               |  34 ++++
 .../traffic-portal/nightwatch/tests/users.spec.ts  |  25 +--
 16 files changed, 415 insertions(+), 340 deletions(-)

diff --git a/.github/actions/tpv2-integration-tests/README.md b/.github/actions/tpv2-integration-tests/README.md
index 3df1b87c57..2575893da1 100644
--- a/.github/actions/tpv2-integration-tests/README.md
+++ b/.github/actions/tpv2-integration-tests/README.md
@@ -19,22 +19,9 @@
 
 # tp-integration-tests javascript action
 this action runs the traffic portal integration tests
-- requires an smtp service (see `smtp_address` input)
 
 ## inputs
 
-### `smtp_address`
-**required** the address of an smtp server for use by traffic ops.
-
-### `smtp_port`
-**required** the address of an smtp server for use by traffic ops. required but defaults to `25`.
-
-### `smtp_user`
-**optional** the user to authenticate with for the smtp server.
-
-### `smtp_password`
-**optional** the password to authenticate with for the smtp server.
-
 ## outputs
 
 ### `exit-code`
@@ -90,7 +77,4 @@ jobs:
         uses: ./.github/actions/todb-init
       - name: Run TP
         uses: ./.github/actions/tpv2-integration-tests
-        with:
-          smtp_address: 172.17.0.1
-
 ```
diff --git a/.github/actions/tpv2-integration-tests/action.yml b/.github/actions/tpv2-integration-tests/action.yml
index 67d2ccc669..043a3317a0 100644
--- a/.github/actions/tpv2-integration-tests/action.yml
+++ b/.github/actions/tpv2-integration-tests/action.yml
@@ -17,27 +17,8 @@
 
 name: 'tpv2-integration-tests'
 description: 'Runs Traffic Portalv2 Integration tests'
-inputs:
-  smtp_address:
-    description: 'Address of an SMTP server to use for the Traffic Ops API tests'
-    required: true
-  smtp_port:
-    description: 'Port of an SMTP server to use for the Traffic Ops API tests'
-    required: true
-    default: '25'
-  smtp_user:
-    description: 'The user to authenticate with for the SMTP server.'
-    required: false
-  smtp_password:
-    description: 'The user to authenticate with for the SMTP server.'
-    required: false
 runs:
   using: composite
   steps:
     - run: ${{ github.action_path }}/entrypoint.sh
       shell: bash
-      env:
-        INPUT_SMTP_USER: ${{ inputs.smtp_user }}
-        INPUT_SMTP_PORT: ${{ inputs.smtp_port }}
-        INPUT_SMTP_ADDRESS: ${{ inputs.smtp_address }}
-        INPUT_SMTP_PASSWORD: ${{ inputs.smtp_password }}
diff --git a/.github/actions/tpv2-integration-tests/cdn.json b/.github/actions/tpv2-integration-tests/cdn.json
index fa6d2895a9..c4f60a624f 100644
--- a/.github/actions/tpv2-integration-tests/cdn.json
+++ b/.github/actions/tpv2-integration-tests/cdn.json
@@ -1,7 +1,7 @@
 {
 	"hypnotoad": {
 		"listen": [
-			"https://not-a-real-host.test:1?cert=$PWD/localhost.crt&key=$PWD/localhost.key&verify=0x00&ciphers=AES128-GCM-SHA256:HIGH:!RC4:!MD5:!aNULL:!EDH:!ED"
+			"https://not-a-real-host.test:1?cert=$GITHUB_WORKSPACE/traffic_ops/traffic_ops_golang/localhost.crt&key=$GITHUB_WORKSPACE/traffic_ops/traffic_ops_golang/localhost.key&verify=0x00&ciphers=AES128-GCM-SHA256:HIGH:!RC4:!MD5:!aNULL:!EDH:!ED"
 		],
 		"user": "trafops",
 		"group": "trafops",
@@ -35,7 +35,7 @@
 			"max_connections": 500,
 			"max_idle_connections": 30,
 			"query_timeout_seconds": 10,
-			"aes_key_location": "/aes.key"
+			"aes_key_location": "$GITHUB_WORKSPACE/aes.key"
 		},
 		"supported_ds_metrics": [ "kbps", "tps_total", "tps_2xx", "tps_3xx", "tps_4xx", "tps_5xx" ]
 	},
@@ -59,9 +59,9 @@
 	"inactivity_timeout": 60,
 	"smtp": {
 		"enabled": true,
-		"user": "$INPUT_SMTP_USER",
-		"password": "$INPUT_SMTP_PASSWORD",
-		"address": "${INPUT_SMTP_ADDRESS}:${INPUT_SMTP_PORT}"
+		"user": "",
+		"password": "",
+		"address": "172.17.0.1:25"
 	},
 	"InfluxEnabled": false
 }
diff --git a/.github/actions/tpv2-integration-tests/entrypoint.sh b/.github/actions/tpv2-integration-tests/entrypoint.sh
index 2765ca400e..b7f03aa004 100755
--- a/.github/actions/tpv2-integration-tests/entrypoint.sh
+++ b/.github/actions/tpv2-integration-tests/entrypoint.sh
@@ -16,97 +16,22 @@
 # specific language governing permissions and limitations
 # under the License.
 
-onFail() {
-  echo "Error on line ${1} of ${2}" >&2;
-  cd "${REPO_DIR}/experimental/traffic-portal"
-  if ! [[ -d Reports ]]; then
-    mkdir Reports;
-  fi
-  if [[ -d nightwatch/junit ]]; then
-    mv nightwatch/junit Reports
-  fi
-  if [[ -d nightwatch/screens ]]; then
-    mv nightwatch/screens Reports
-  fi
-  if [[ -d logs ]]; then
-    mv logs Reports
-  fi
-  if [[ -f "${REPO_DIR}/traffic_ops/traffic_ops_golang" ]]; then
-    cp "${REPO_DIR}/traffic_ops/traffic_ops_golang" Reports/to.log;
-  fi
-  echo "Detailed logs produced info Reports artifact"
-  exit 1
-}
+set -ex
 
-trap 'onFail "${LINENO}" "${0}"' ERR
-set -o errexit -o nounset -o pipefail
-
-to_fqdn="https://localhost:6443"
-tp_fqdn="http://localhost:4200"
-
-export PGUSER="traffic_ops"
-export PGPASSWORD="twelve"
-export PGHOST="localhost"
-export PGDATABASE="traffic_ops"
-export PGPORT="5432"
-
-to_admin_username="admin"
-to_admin_password="twelve12"
-password_hash="$(<<PYTHON_COMMANDS PYTHONPATH="${GITHUB_WORKSPACE}/traffic_ops/install/bin" python
-import _postinstall
-print(_postinstall.hash_pass('${to_admin_password}'))
-PYTHON_COMMANDS
-)"
-<<QUERY psql
-INSERT INTO tm_user (username, role, tenant_id, local_passwd)
-  VALUES ('${to_admin_username}', 1, 1,
-    '${password_hash}'
-  );
-QUERY
-
-sudo useradd trafops
-
-ciab_dir="${GITHUB_WORKSPACE}/infrastructure/cdn-in-a-box";
-openssl rand 32 | base64 | sudo tee /aes.key
-
-sudo apt-get install -y --no-install-recommends gettext curl
-
-export GOPATH="${HOME}/go"
-readonly ORG_DIR="$GOPATH/src/github.com/apache"
-readonly REPO_DIR="${ORG_DIR}/trafficcontrol"
-resources="$(dirname "$0")"
-if [[ ! -e "$REPO_DIR" ]]; then
-	mkdir -p "$ORG_DIR"
-	cd
-	mv "${GITHUB_WORKSPACE}" "${REPO_DIR}/"
-	ln -s "$REPO_DIR" "${GITHUB_WORKSPACE}"
-fi
-
-pushd "${REPO_DIR}/traffic_ops/traffic_ops_golang"
-if  [[ ! -d "${GITHUB_WORKSPACE}/vendor/golang.org" ]]; then
-  go mod vendor
-fi
-go build .
-
-openssl req -new -x509 -nodes -newkey rsa:4096 -out localhost.crt -keyout localhost.key -subj "/CN=tptests";
-
-envsubst <"${resources}/cdn.json" >cdn.conf
-cp "${resources}/database.json" database.conf
+cd "${GITHUB_WORKSPACE}/traffic_ops/traffic_ops_golang"
 
 truncate -s0 out.log
-./traffic_ops_golang --cfg ./cdn.conf --dbcfg ./database.conf >out.log 2>&1 &
-popd
+envsubst <../../.github/actions/tpv2-integration-tests/cdn.json >./cdn.conf
+
+./traffic_ops_golang --cfg ./cdn.conf --dbcfg ../../.github/actions/tpv2-integration-tests/database.json > out.log 2>&1 &
 
-cd "${REPO_DIR}/experimental/traffic-portal"
-npm ci
+cd "${GITHUB_WORKSPACE}/experimental/traffic-portal"
 npx ng serve &
 
-# Wait for tp/to build
 timeout 15m bash <<TMOUT
-  while ! curl -Lvsk "${tp_fqdn}/api/4.0/ping" >/dev/null 2>&1; do
-    echo "waiting for TP/TO server to start on '${tp_fqdn}'"
-    sleep 30
-  done
+	while ! curl -k "http://localhost:4200/api/4.0/ping" >/dev/null 2>&1; do
+		echo "waiting for TP dev server to proxy TO API"
+		sleep 5
+	done
 TMOUT
-
-npm run e2e:ci
+timeout 15m npm run e2e:ci
diff --git a/.github/workflows/tpv2.integration.tests.yml b/.github/workflows/tpv2.integration.tests.yml
deleted file mode 100644
index 19c367e7ad..0000000000
--- a/.github/workflows/tpv2.integration.tests.yml
+++ /dev/null
@@ -1,115 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-name: TPv2 Integration Tests
-
-env:
-  # alpine:3.13
-  ALPINE_VERSION: sha256:08d6ca16c60fe7490c03d10dc339d9fd8ea67c6466dea8d558526b1330a85930
-
-on:
-  push:
-    paths:
-      - .github/actions/tpv2-integration-tests/**
-      - .github/workflows/tpv2.integration.tests.yml
-      - experimental/traffic-portal/**
-      # Uncomment these when TPv2 is no longer experimental
-      # - .github/actions/todb-init/**
-      # - .github/actions/tvdb-init/**
-      # - GO_VERSION
-      # - infrastructure/cdn-in-a-box/optional/traffic_vault/**
-      # - traffic_ops/*client/**.go
-      # - traffic_ops/testing/api/**.go
-      #- traffic_ops/traffic_ops_golang/**.go
-  create:
-  pull_request:
-    paths:
-      - .github/actions/tpv2-integration-tests/**
-      - .github/workflows/tpv2.integration.tests.yml
-      - experimental/traffic-portal/**
-    types: [ opened, reopened, ready_for_review, synchronize ]
-
-jobs:
-  TP_Integration_tests:
-    if: github.event.pull_request.draft == false
-    runs-on: ubuntu-latest
-    services:
-      postgres:
-        image: postgres:11
-        env:
-          POSTGRES_USER: traffic_ops
-          POSTGRES_PASSWORD: twelve
-          POSTGRES_DB: traffic_ops
-        ports:
-          - 5432:5432
-        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
-      smtp:
-        image: maildev/maildev:2.0.0-beta3
-        ports:
-          - 25:25
-        options: >-
-          --entrypoint=bin/maildev
-          --user=root
-          --health-cmd="sh -c \"[[ \$(wget -qO- http://smtp/healthz) == true ]]\""
-          --
-          maildev/maildev:2.0.0-beta3
-          --smtp=25
-          --hide-extensions=STARTTLS
-          --web=80
-
-    steps:
-      - name: Checkout
-        uses: actions/checkout@master
-      - name: Cache Alpine Docker image
-        uses: actions/cache@v2
-        with:
-          path: ${{ github.workspace }}/docker-images
-          key: docker-images/alpine@${{ env.ALPINE_VERSION }}.tar.gz
-      - name: Import cached Alpine Docker image
-        run: .github/actions/save-alpine-tar/entrypoint.sh load ${{ env.ALPINE_VERSION }}
-      - name: Cache node modules
-        uses: actions/cache@v2
-        with:
-          path: ./experimental/traffic-portal/node_modules
-          key: ${{ runner.os }}-node-${{ hashFiles('./experimental/traffic-portal/package-lock.json') }}
-          restore-keys: |
-            ${{ runner.os }}-node-modules-
-      - name: Initialize Traffic Ops Database
-        id: todb
-        uses: ./.github/actions/todb-init
-      - name: Initialize Traffic Vault Database
-        id: tvdb
-        uses: ./.github/actions/tvdb-init
-      - name: Check Go Version
-        run: echo "::set-output name=value::$(cat GO_VERSION)"
-        id: go-version
-      - name: Install Go
-        uses: actions/setup-go@v2
-        with:
-          go-version: ${{ steps.go-version.outputs.value }}
-      - name: Run TP
-        uses: ./.github/actions/tpv2-integration-tests
-        with:
-          smtp_address: 172.17.0.1
-      - name: Upload Report
-        uses: actions/upload-artifact@v2
-        if: always()
-        with:
-          name: ${{ github.job }}
-          path: ${{ github.workspace }}/expirimental/traffic-portal/Reports
-      - name: Save Alpine Docker image
-        run: .github/actions/save-alpine-tar/entrypoint.sh save ${{ env.ALPINE_VERSION }}
diff --git a/.github/workflows/tpv2.yml b/.github/workflows/tpv2.yml
index 70e93a450b..066f55a944 100644
--- a/.github/workflows/tpv2.yml
+++ b/.github/workflows/tpv2.yml
@@ -14,25 +14,32 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-name: Lint and Test Experimental Traffic Portal
+name: Experimental Traffic Portal v2
+
+env:
+  # alpine:3.13
+  ALPINE_VERSION: sha256:08d6ca16c60fe7490c03d10dc339d9fd8ea67c6466dea8d558526b1330a85930
 
 on:
   pull_request:
     paths:
       - experimental/traffic-portal/**
       - .github/workflows/tpv2.yml
-    types: [opened, reopened, edited, synchronize]
+      - .github/actions/tpv2-integration-tests
+    types: [opened, reopened, ready_for_review, synchronize]
 
 jobs:
   build:
+    if: github.event.pull_request.draft == false
     runs-on: ubuntu-latest
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v1
+        uses: actions/checkout@v3
 
       - name: Cache node modules
-        uses: actions/cache@v1
+        id: restore-npm-cache
+        uses: actions/cache@v3
         with:
           path: ./experimental/traffic-portal/node_modules
           key: ${{ runner.os }}-node-${{ hashFiles('./experimental/traffic-portal/package-lock.json') }}
@@ -40,14 +47,12 @@ jobs:
             ${{ runner.os }}-node-
 
       - name: Node 16
-        uses: actions/setup-node@v1
+        uses: actions/setup-node@v3
         with:
           node-version: 16.x
 
-      - name: Install latest Chrome
-        run: sudo apt-get update && sudo apt-get install google-chrome-stable
-
       - name: NPM install
+        if: steps.restore-npm-cache.cache-hit != 'true'
         run: |
           cd experimental/traffic-portal/
           npm ci
@@ -56,13 +61,170 @@ jobs:
         run: |
           cd experimental/traffic-portal/
           npm run build:ssr
+  lint:
+    if: github.event.pull_request.draft == false
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Cache node modules
+        id: restore-npm-cache
+        uses: actions/cache@v3
+        with:
+          path: ./experimental/traffic-portal/node_modules
+          key: ${{ runner.os }}-node-${{ hashFiles('./experimental/traffic-portal/package-lock.json') }}
+          restore-keys: |
+            ${{ runner.os }}-node-
+
+      - name: Node 16
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16.x
+
+      - name: NPM install
+        if: steps.restore-npm-cache.cache-hit != 'true'
+        run: |
+          cd experimental/traffic-portal/
+          npm ci
 
       - name: Lint
         run: |
           cd experimental/traffic-portal/
           npm run lint
+  unit-tests:
+    if: github.event.pull_request.draft == false
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Cache node modules
+        id: restore-npm-cache
+        uses: actions/cache@v3
+        with:
+          path: ./experimental/traffic-portal/node_modules
+          key: ${{ runner.os }}-node-${{ hashFiles('./experimental/traffic-portal/package-lock.json') }}
+          restore-keys: |
+            ${{ runner.os }}-node-
+
+      - name: Node 16
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16.x
+
+      - name: Install latest Chrome
+        run: sudo apt-get update && sudo apt-get install google-chrome-stable
+
+      - name: NPM install
+        if: steps.restore-npm-cache.cache-hit != 'true'
+        run: |
+          cd experimental/traffic-portal/
+          npm ci
 
       - name: Test
         run: |
           cd experimental/traffic-portal/
           npm run test:ci
+  end-to-end-tests:
+    if: github.event.pull_request.draft == false
+    runs-on: ubuntu-latest
+    env:
+      PGUSER: traffic_ops
+      PGPASSWORD: twelve
+      PGHOST: localhost
+      PGDATABASE: traffic_ops
+      PGPORT: 5432
+    services:
+      postgres:
+        image: postgres:13
+        env:
+          POSTGRES_USER: traffic_ops
+          POSTGRES_PASSWORD: twelve
+          POSTGRES_DB: traffic_ops
+        ports:
+          - 5432:5432
+        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
+      smtp:
+        image: maildev/maildev:2.0.0-beta3
+        ports:
+          - 25:25
+        options: >-
+          --entrypoint=bin/maildev
+          --user=root
+          --health-cmd="sh -c \"[[ \$(wget -qO- http://smtp/healthz) == true ]]\""
+          --
+          maildev/maildev:2.0.0-beta3
+          --smtp=25
+          --hide-extensions=STARTTLS
+          --web=80
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+      - name: Cache Alpine Docker image
+        uses: actions/cache@v3
+        with:
+          path: ${{ github.workspace }}/docker-images
+          key: docker-images/alpine@${{ env.ALPINE_VERSION }}.tar.gz
+      - name: Import cached Alpine Docker image
+        run: .github/actions/save-alpine-tar/entrypoint.sh load ${{ env.ALPINE_VERSION }}
+      - name: Cache node modules
+        id: restore-npm-cache
+        uses: actions/cache@v3
+        with:
+          path: ./experimental/traffic-portal/node_modules
+          key: ${{ runner.os }}-node-${{ hashFiles('./experimental/traffic-portal/package-lock.json') }}
+          restore-keys: |
+            ${{ runner.os }}-node-modules-
+      - name: Initialize Traffic Ops Database
+        id: todb
+        uses: ./.github/actions/todb-init
+      - name: Initialize Traffic Vault Database
+        id: tvdb
+        uses: ./.github/actions/tvdb-init
+      - name: Check Go Version
+        run: echo "::set-output name=value::$(cat GO_VERSION)"
+        id: go-version
+      - name: Install Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: ${{ steps.go-version.outputs.value }}
+      - name: Build Traffic Ops
+        run: |
+          cd "${GITHUB_WORKSPACE}/traffic_ops/traffic_ops_golang"
+          go build .
+
+      # Setup
+      - name: Install dependencies
+        run: sudo apt-get update && sudo apt-get install postgresql-client gettext-base
+      - name: Create admin user
+        run: |
+          psql -c "INSERT INTO tm_user (username, role, tenant_id, local_passwd) VALUES ('admin', 1, 1, 'SCRYPT:16384:8:1:p0Bppp/6IBeYxSwdLuYddsdMLBU/BNSlLY6fSIF7H1XW4eTbNVeMPVm7TuTEG4FM8PbqLlVwi8sPy8ZJznAlaQ==:sRcHWGe43mm/uEmXTIw37GcLEQZTlWAdf4vJqK8f0MDh8P+8gXoNx+nxWyb3r/0Bh+yyg0g/dUvti/ePZJL+Jw==');"
+      - name: Create SSL Certificates and AES key
+        run: |
+          openssl rand 32 | base64 | tee "${GITHUB_WORKSPACE}/aes.key"
+          openssl req -new -x509 -nodes -newkey rsa:4096 -out traffic_ops/traffic_ops_golang/localhost.crt -keyout traffic_ops/traffic_ops_golang/localhost.key -subj "/CN=tptests"
+      - name: NPM install
+        if: steps.restore-npm-cache.cache-hit != 'true'
+        run: |
+          cd experimental/traffic-portal
+          npm ci
+      - name: Run everything and test
+        uses: ./.github/actions/tpv2-integration-tests
+      - name: Upload Report
+        uses: actions/upload-artifact@v3
+        if: always()
+        with:
+          name: ${{ github.job }}
+          path: |
+            traffic_ops/traffic_ops_golang/out.log
+            experimental/traffic-portal/logs
+            experimental/traffic-portal/nightwatch/junit
+            experimental/traffic-portal/nightwatch/screens
+            experimental/traffic-portal/tests_output
+
+      - name: Save Alpine Docker image
+        run: .github/actions/save-alpine-tar/entrypoint.sh save ${{ env.ALPINE_VERSION }}
diff --git a/experimental/traffic-portal/.eslintrc.json b/experimental/traffic-portal/.eslintrc.json
index 9e49372860..8ff5ffa0c1 100644
--- a/experimental/traffic-portal/.eslintrc.json
+++ b/experimental/traffic-portal/.eslintrc.json
@@ -177,6 +177,12 @@
 						"ignoreParameters": true
 					}
 				],
+				"@typescript-eslint/no-invalid-this": [
+					"error",
+					{
+						"capIsConstructor": false
+					}
+				],
 				"@typescript-eslint/no-invalid-void-type": "error",
 				"@typescript-eslint/no-misused-new": "error",
 				"@typescript-eslint/no-misused-promises": "error",
@@ -308,12 +314,7 @@
 				"no-else-return": "error",
 				"no-empty": "error",
 				"no-extra-bind": "error",
-				"no-invalid-this": [
-					"error",
-					{
-						"capIsConstructor": false
-					}
-				],
+				"no-invalid-this": "off",
 				"no-multiple-empty-lines": [
 					"error",
 					{
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/globals.ts
index d1ed9e379a..a624e86707 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/globals.ts
@@ -23,13 +23,9 @@ export interface GlobalConfig extends NightwatchGlobals {
 	trafficOpsURL: string;
 }
 const config = {
-	chrome_headless: {},
-	default: {
-		adminPass: "twelve12",
-		adminUser: "admin",
-		trafficOpsURL: "https://localhost:6443"
-	}
+	adminPass: "twelve12",
+	adminUser: "admin",
+	trafficOpsURL: "https://localhost:6443"
 };
-config.chrome_headless = config.default;
 
 module.exports = config;
diff --git a/experimental/traffic-portal/nightwatch/globals/globals.ts b/experimental/traffic-portal/nightwatch/globals/index.ts
similarity index 55%
copy from experimental/traffic-portal/nightwatch/globals/globals.ts
copy to experimental/traffic-portal/nightwatch/globals/index.ts
index d1ed9e379a..cf939cab87 100644
--- a/experimental/traffic-portal/nightwatch/globals/globals.ts
+++ b/experimental/traffic-portal/nightwatch/globals/index.ts
@@ -1,5 +1,4 @@
 /*
-*
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
@@ -12,24 +11,15 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-import {type NightwatchGlobals} from "nightwatch";
+
+import type { NightwatchBrowser } from "nightwatch";
+
+import type { GlobalConfig } from "./globals";
 
 /**
- * Defines the configuration used for the testing environment
+ * A test suite is a mapping of test descriptions to the functions that
+ * implement the thereby described test.
  */
-export interface GlobalConfig extends NightwatchGlobals {
-	adminPass: string;
-	adminUser: string;
-	trafficOpsURL: string;
+export interface TestSuite {
+	[description: string]: (browser: NightwatchBrowser & {globals: GlobalConfig}) => (void | Promise<void>);
 }
-const config = {
-	chrome_headless: {},
-	default: {
-		adminPass: "twelve12",
-		adminUser: "admin",
-		trafficOpsURL: "https://localhost:6443"
-	}
-};
-config.chrome_headless = config.default;
-
-module.exports = config;
diff --git a/experimental/traffic-portal/nightwatch/globals/tables/index.ts b/experimental/traffic-portal/nightwatch/globals/tables/index.ts
new file mode 100644
index 0000000000..d48af8f7b9
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/globals/tables/index.ts
@@ -0,0 +1,99 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import type { EnhancedElementInstance, EnhancedPageObject, EnhancedSectionInstance } from "nightwatch";
+
+/**
+ * TableSectionCommands is the base type for page object sections representing
+ * pages containing AG-Grid generic tables.
+ */
+export interface TableSectionCommands extends EnhancedSectionInstance, EnhancedElementInstance<EnhancedPageObject> {
+	getColumnState(column: string): Promise<boolean>;
+	searchText<T extends this>(text: string): T;
+	toggleColumn<T extends this>(column: string): T;
+}
+
+/**
+ * A CSS selector for an AG-Grid generic table's column visibility dropdown
+ * menu.
+ */
+export const columnMenuSelector = "button.dropdown-toggle";
+
+/**
+ * A CSS selector for an AG-Grid generic table's "Fuzzy Search" input text box.
+ */
+export const searchboxSelector = "input[name='fuzzControl']";
+
+/**
+ * Gets the state of an AG-Grid column by checking whether or not it's checked
+ * in the column visibility menu (doesn't actually verify that this means the
+ * column is visible).
+ *
+ * @param this Special parameter that tells the compiler what `this` is in a
+ * valid context for this function.
+ * @param column The name of the column being retrieved.
+ * @returns The state of the column named `column`. Behavior is undefined if
+ * multiple columns exist with the same given name.
+ */
+export async function getColumnState(this: TableSectionCommands, column: string): Promise<boolean> {
+	return new Promise((resolve, reject) => {
+		this.click(columnMenuSelector).getElementProperty(`input[name='column-${column}']`, "checked",
+			result => {
+				if (typeof result.value !== "boolean") {
+					console.error("incorrect type for 'checked' DOM property:", result.value);
+					reject(new Error(`incorrect type for 'checked' DOM property: ${typeof result.value}`));
+					return;
+				}
+				this.click(columnMenuSelector);
+				resolve(result.value);
+			}
+		);
+	});
+}
+
+/**
+ * Sets the text of the table's "Fuzzy Search" searchbox.
+ *
+ * @param this Special parameter that tells the compiler what `this` is in a
+ * valid context for this function.
+ * @param text The text to set in the search input.
+ * @returns The calling command section for call-chaining the way Nightwatch
+ * likes to do.
+ */
+export function searchText<T extends TableSectionCommands>(this: T, text: string): T  {
+	return this.setValue(searchboxSelector, text);
+}
+
+/**
+ * Toggles the presence of a given column.
+ *
+ * @param this Special parameter that tells the compiler what `this` is in a
+ * valid context for this function.
+ * @param column The name of the column to be toggled.
+ * @returns The calling command section for call-chaining the way Nightwatch
+ * likes to do.
+ */
+export function toggleColumn<T extends TableSectionCommands>(this: T, column: string): T {
+	return this.click(columnMenuSelector).click(`input[name='${column}']`).click(columnMenuSelector);
+}
+
+/**
+ * This is meant to be mixed-in to generic table page object command sections,
+ * to most easily provide all the functionality of a table.
+ */
+export const TABLE_COMMANDS = {
+	getColumnState,
+	searchText,
+	toggleColumn
+};
diff --git a/experimental/traffic-portal/nightwatch/nightwatch.conf.js b/experimental/traffic-portal/nightwatch/nightwatch.conf.js
index a17707d4c4..eb6cf1d1fe 100644
--- a/experimental/traffic-portal/nightwatch/nightwatch.conf.js
+++ b/experimental/traffic-portal/nightwatch/nightwatch.conf.js
@@ -62,7 +62,8 @@ module.exports = {
 			desiredCapabilities: {
 				"goog:chromeOptions": {
 					args: [
-						"--headless"
+						"--headless",
+						"--window-size=1920,1080"
 					]
 				}
 			},
diff --git a/experimental/traffic-portal/nightwatch/page_objects/servers.ts b/experimental/traffic-portal/nightwatch/page_objects/servers.ts
new file mode 100644
index 0000000000..79e8bf5246
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/page_objects/servers.ts
@@ -0,0 +1,55 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import {
+	EnhancedPageObject,
+	EnhancedSectionInstance,
+	NightwatchAPI
+} from "nightwatch";
+
+import { TableSectionCommands, TABLE_COMMANDS } from "../globals/tables";
+
+/**
+ * Defines the commands for the servers table section.
+ */
+type ServersTableSectionCommands = TableSectionCommands;
+
+const serversPageObject = {
+	api: {} as NightwatchAPI,
+	sections: {
+		serversTable: {
+			commands: {
+				...TABLE_COMMANDS
+			} as ServersTableSectionCommands,
+			elements: {
+			},
+			selector: "servers-table main"
+		}
+	},
+	url(): string {
+		return `${this.api.launchUrl}/core/servers`;
+	}
+};
+
+/**
+ * Defines the servers table section.
+ */
+type ServersTableSection = EnhancedSectionInstance<ServersTableSectionCommands, typeof serversPageObject.sections.serversTable.elements>;
+
+/**
+ * The type of the servers table page object as provided by the Nightwatch API at
+ * runtime.
+ */
+export type ServersPageObject = EnhancedPageObject<{}, {}, { serversTable: ServersTableSection }>;
+
+export default serversPageObject;
diff --git a/experimental/traffic-portal/nightwatch/page_objects/users.ts b/experimental/traffic-portal/nightwatch/page_objects/users.ts
index b5fd50e380..02ca591aaa 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/users.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/users.ts
@@ -12,54 +12,26 @@
 * limitations under the License.
 */
 import {
-	EnhancedElementInstance,
 	EnhancedPageObject,
 	EnhancedSectionInstance,
 	NightwatchAPI
 } from "nightwatch";
 
+import { TableSectionCommands, TABLE_COMMANDS } from "../globals/tables";
+
 /**
  * Defines the commands for the users table section.
  */
-interface UsersTableSectionCommands extends EnhancedSectionInstance, EnhancedElementInstance<EnhancedPageObject> {
-	getColumnState(column: string): Promise<boolean>;
-	searchText(text: string): this;
-	toggleColumn(column: string): this;
-}
+type UsersTableSectionCommands = TableSectionCommands;
 
 const usersPageObject = {
 	api: {} as NightwatchAPI,
 	sections: {
 		usersTable: {
 			commands: {
-				async getColumnState(column: string): Promise<boolean> {
-					return new Promise((resolve, reject) => {
-						this.click("@columnMenu").getElementProperty(`input[name='column-${column}']`, "checked",
-							result => {
-								if (typeof result.value !== "boolean") {
-									console.error("incorrect type for 'checked' DOM property:", result.value);
-									reject(new Error(`incorrect type for 'checked' DOM property: ${typeof result.value}`));
-									return;
-								}
-								resolve(result.value);
-							}
-						).click("@columnMenu");
-					});
-				},
-				searchText(text: string): UsersTableSectionCommands  {
-					 return this.setValue("@searchbox", text);
-				},
-				toggleColumn(column: string): UsersTableSectionCommands {
-					return this.click("@columnMenu").click(`input[name='${column}']`).click("@columnMenu");
-				},
+				...TABLE_COMMANDS
 			} as UsersTableSectionCommands,
 			elements: {
-				columnMenu: {
-					selector: "button.dropdown-toggle"
-				},
-				searchbox: {
-					selector: "input[name='fuzzControl']"
-				},
 			},
 			selector: "main > main"
 		}
diff --git a/experimental/traffic-portal/nightwatch/tests/login.spec.ts b/experimental/traffic-portal/nightwatch/tests/login.spec.ts
index 0f8e928258..ec92912df0 100644
--- a/experimental/traffic-portal/nightwatch/tests/login.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/login.spec.ts
@@ -11,13 +11,11 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-import {type NightwatchBrowser} from "nightwatch";
+import type { TestSuite } from "../globals";
+import type { LoginPageObject } from "../page_objects/login";
 
-import {type GlobalConfig} from "../globals/globals";
-import {type LoginPageObject} from "../page_objects/login";
-
-module.exports = {
-	"Clear form test": (browser: NightwatchBrowser): void => {
+const suite: TestSuite = {
+	"Clear form test": browser => {
 		const page: LoginPageObject = browser.page.login();
 		page.navigate()
 			.section.loginForm
@@ -27,7 +25,7 @@ module.exports = {
 			.assert.containsText("@passwordTxt", "")
 			.end();
 	},
-	"Incorrect password test":  (browser: NightwatchBrowser): void => {
+	"Incorrect password test":  browser => {
 		const page: LoginPageObject = browser.page.login();
 		page.navigate()
 			.section.loginForm
@@ -38,14 +36,15 @@ module.exports = {
 			.assert.containsText("@snackbarEle", "Invalid")
 			.end();
 	},
-	"Login test": (browser: NightwatchBrowser): void => {
+	"Login test": browser => {
 		const page: LoginPageObject = browser.page.login();
-		const globals = browser.globals as GlobalConfig;
 		page.navigate()
 			.section.loginForm
-			.login(globals.adminUser, globals.adminPass)
+			.login(browser.globals.adminUser, browser.globals.adminPass)
 			.parent
 			.assert.containsText("@snackbarEle", "Success")
 			.end();
 	}
 };
+
+export default suite;
diff --git a/experimental/traffic-portal/nightwatch/tests/servers.spec.ts b/experimental/traffic-portal/nightwatch/tests/servers.spec.ts
new file mode 100644
index 0000000000..301683b01e
--- /dev/null
+++ b/experimental/traffic-portal/nightwatch/tests/servers.spec.ts
@@ -0,0 +1,34 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import type { TestSuite } from "../globals";
+import type { LoginPageObject } from "../page_objects/login";
+import type { ServersPageObject } from "../page_objects/servers";
+
+const suite: TestSuite = {
+	"Filter by hostname": async browser => {
+		const username = browser.globals.adminUser;
+		const password = browser.globals.adminPass;
+
+		const loginPage: LoginPageObject = browser.page.login();
+		loginPage.navigate().section.loginForm.login(username, password);
+
+		const page: ServersPageObject = browser.waitForElementPresent("main").page.servers().navigate();
+		page.pause(4000);
+		let tbl = page.waitForElementPresent("input[name=fuzzControl]").section.serversTable;
+		tbl = tbl.searchText("edge");
+		tbl.parent.assert.urlContains("search=edge").end();
+	}
+};
+
+export default suite;
diff --git a/experimental/traffic-portal/nightwatch/tests/users.spec.ts b/experimental/traffic-portal/nightwatch/tests/users.spec.ts
index 31630f563c..a74ee7f00b 100644
--- a/experimental/traffic-portal/nightwatch/tests/users.spec.ts
+++ b/experimental/traffic-portal/nightwatch/tests/users.spec.ts
@@ -11,24 +11,14 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-import type { NightwatchBrowser } from "nightwatch";
-import { LoginPageObject } from "nightwatch/page_objects/login";
-
-import type { GlobalConfig } from "../globals/globals";
+import type { TestSuite } from "../globals";
+import { LoginPageObject } from "../page_objects/login";
 import type { UsersPageObject } from "../page_objects/users";
 
-/**
- * A test suite is a mapping of test descriptions to the functions that
- * implement the thereby described test.
- */
-interface TestSuite {
-	[description: string]: (browser: NightwatchBrowser) => (void | Promise<void>);
-}
-
 const suite: TestSuite = {
 	"Filter by username": async browser => {
-		const username = (browser.globals as GlobalConfig).adminUser;
-		const password = (browser.globals as GlobalConfig).adminPass;
+		const username = browser.globals.adminUser;
+		const password = browser.globals.adminPass;
 
 		const loginPage: LoginPageObject = browser.page.login();
 		loginPage.navigate().section.loginForm.login(username, password);
@@ -48,14 +38,15 @@ const suite: TestSuite = {
 					browser.assert.equal(true, false, `failed to select ag-grid rows: ${result.value.message}`);
 					return;
 				}
-				browser.assert.equal(result.value.length, 1);
+				browser.assert.equal(result.value.length, 1)
+					.end();
 			}
 		);
 	},
 	// Uncomment when user details page exists
 	// "View user details":  browser => {
-	// 	const username = (browser.globals as GlobalConfig).adminUser;
-	// 	const password = (browser.globals as GlobalConfig).adminPass;
+	// 	const username = browser.globals.adminUser;
+	// 	const password = browser.globals.adminPass;
 
 	// 	const loginPage: LoginPageObject = browser.page.login();
 	// 	loginPage.navigate().section.loginForm.login(username, password);